///|
struct Inspector {
  sessions : Map[Int32, Analysis]
  mut next_id : Int32
  capacity : Int32
}

///|
pub fn Inspector::new(max_snapshots~ : Int32 = 2) -> Inspector {
  {
    sessions: Map([]),
    next_id: 1,
    capacity: max_snapshots.clamp(min=1, max=8),
  }
}

///|
pub fn Inspector::open(
  self : Inspector,
  input : String,
  limits~ : Limits = Limits::default(),
) -> Int32 raise HeapError {
  self.accept(parse(input, limits~))
}

///|
pub fn Inspector::accept(
  self : Inspector,
  snapshot : Snapshot,
) -> Int32 raise HeapError {
  guard self.sessions.length() < self.capacity else {
    raise Limit("snapshot session capacity exceeded")
  }
  guard self.next_id < 2147483647 else {
    raise Limit("inspector handle range exhausted")
  }
  let a = snapshot.graph().analyze()
  let id = self.next_id
  self.next_id += 1
  self.sessions[id] = a
  id
}

///|
pub fn Inspector::close(self : Inspector, handle : Int32) -> Unit {
  self.sessions.remove(handle)
}

///|
fn Inspector::get(self : Inspector, handle : Int32) -> Analysis raise HeapError {
  match self.sessions.get(handle) {
    Some(a) => a
    None => raise NotFound("snapshot handle \{handle}")
  }
}

///|
fn request(input : String) -> Json raise HeapError {
  guard input.length() <= 8192 else { raise Limit("request too large") }
  let v = @json.parse(input, reject_duplicate_keys=true, max_nesting_depth=16) catch {
    _ => raise Invalid("invalid request JSON")
  }
  guard v is Object(_) else { raise Invalid("request must be an object") }
  v
}

///|
fn optional(doc : Json, key : String) -> Json? {
  match doc {
    Object(m) => m.get(key)
    _ => None
  }
}

///|
fn string_option(
  doc : Json,
  key : String,
  fallback : String,
) -> String raise HeapError {
  match optional(doc, key) {
    None => fallback
    Some(v) => text(v, key)
  }
}

///|
fn int_option(
  doc : Json,
  key : String,
  fallback : Int32,
) -> Int32 raise HeapError {
  match optional(doc, key) {
    None => fallback
    Some(v) => integer(v, key)
  }
}

///|
fn bool_option(
  doc : Json,
  key : String,
  fallback : Bool,
) -> Bool raise HeapError {
  match optional(doc, key) {
    None => fallback
    Some(True) => true
    Some(False) => false
    _ => raise Invalid("\{key}: expected boolean")
  }
}

///|
fn selected_node(s : Snapshot, req : Json) -> Int32 raise HeapError {
  match optional(req, "id") {
    Some(v) => {
      let id = text(v, "id")
      match s.find_id(id) {
        Some(i) => i
        None => raise NotFound("object id \{id}")
      }
    }
    None => {
      let i = integer(field(req, "index"), "index")
      ignore(s.node(i))
      i
    }
  }
}

///|
// Sizes are limited to the JS-safe range by the parser. IDs stay strings.
fn bytes_json(value : Int64) -> Json {
  @json.to_json(value.to_double())
}

///|
fn optional_bytes(value : Int64?) -> Json {
  match value {
    Some(n) => bytes_json(n)
    None => Json::null()
  }
}

///|
fn optional_count(value : Int32?) -> Json {
  match value {
    Some(n) => @json.to_json(n)
    None => Json::null()
  }
}

///|
fn node_json(a : Analysis, n : Node) -> Json {
  let parent = if n.index == a.graph.snapshot.root || a.idom[n.index] < 0 {
    Json::null()
  } else {
    @json.to_json(a.graph.snapshot.nodes[a.idom[n.index]].id)
  }
  {
    "index": @json.to_json(n.index),
    "id": @json.to_json(n.id),
    "kind": @json.to_json(n.kind),
    "name": @json.to_json(n.name),
    "shallow_bytes": bytes_json(n.self_size),
    "retained_bytes": optional_bytes(
      if a.retained[n.index] < 0L {
        None
      } else {
        Some(a.retained[n.index])
      },
    ),
    "distance": optional_count(
      if a.graph.distance[n.index] < 0 {
        None
      } else {
        Some(a.graph.distance[n.index])
      },
    ),
    "dominator_id": parent,
    "dominated_count": @json.to_json(
      a.dominated_offsets[n.index + 1] - a.dominated_offsets[n.index],
    ),
  }
}

///|
fn edge_json(a : Analysis, e : Edge) -> Json {
  {
    "index": @json.to_json(e.index),
    "kind": @json.to_json(e.kind),
    "name": @json.to_json(e.name),
    "retaining": @json.to_json(a.graph.retaining[e.index]),
    "source": node_json(a, a.graph.snapshot.nodes[e.source]),
    "target": node_json(a, a.graph.snapshot.nodes[e.target]),
  }
}

///|
fn node_page_json(a : Analysis, p : NodePage) -> Json {
  {
    "items": @json.to_json(p.items.map(n => node_json(a, n))),
    "total": @json.to_json(p.total),
    "offset": @json.to_json(p.offset),
  }
}

///|
pub fn Inspector::query(
  self : Inspector,
  handle : Int32,
  input : String,
) -> Json raise HeapError {
  let req = request(input)
  let a = self.get(handle)
  let s = a.graph.snapshot
  let op = string_option(req, "op", "summary")
  let offset = int_option(req, "offset", 0)
  let limit = int_option(req, "limit", 100)
  page_bounds(offset, limit)
  match op {
    "summary" =>
      {
        "nodes": @json.to_json(s.node_count()),
        "edges": @json.to_json(s.edge_count()),
        "root_id": @json.to_json(s.nodes[s.root].id),
        "reachable_nodes": @json.to_json(a.reachable_count),
        "unreachable_nodes": @json.to_json(s.node_count() - a.reachable_count),
        "total_shallow_bytes": bytes_json(s.total_size),
        "reachable_bytes": bytes_json(a.retained[s.root]),
        "policy": "snapshot-strong-v1",
        "conditional_weakmap_edges": @json.to_json(a.graph.conditional_edges),
        "dominator_iterations": @json.to_json(a.iterations),
        "interpretation": "Reported-strong graph bytes; not RSS, GC prediction, or an automatic leak verdict. WeakMap conditional edges remain conservative graph edges.",
      }
    "nodes" =>
      node_page_json(
        a,
        a.nodes(
          order=string_option(req, "order", "retained"),
          query=string_option(req, "query", ""),
          kind=string_option(req, "kind", ""),
          min_retained=size_option(req, "min_retained", -1L),
          min_shallow=size_option(req, "min_shallow", 0L),
          reachability=string_option(req, "reachability", "all"),
          offset~,
          limit~,
        ),
      )
    "groups" => {
      let q = string_option(req, "query", "")
      let kind = string_option(req, "kind", "")
      let groups = a
        .groups()
        .filter(g => {
          (q == "" || g.name.contains(q)) && (kind == "" || g.kind == kind)
        })
      let items : Array[Json] = []
      for i in 0..= offset && items.length() < limit {
          let g = groups[i]
          items.push({
            "kind": @json.to_json(g.kind),
            "name": @json.to_json(g.name),
            "count": @json.to_json(g.count),
            "shallow_bytes": bytes_json(g.shallow_bytes),
            "max_retained_bytes": optional_bytes(g.max_retained_bytes),
          })
        }
      }
      {
        "items": @json.to_json(items),
        "total": @json.to_json(groups.length()),
        "offset": @json.to_json(offset),
      }
    }
    "node" => node_json(a, s.node(selected_node(s, req)))
    "source" => self.source_query(handle, req)
    "dominated" =>
      node_page_json(a, a.dominated(selected_node(s, req), offset~, limit~))
    "references" => {
      let p = a.graph.references(
        selected_node(s, req),
        incoming=bool_option(req, "incoming", true),
        retaining_only=bool_option(req, "retaining_only", false),
        offset~,
        limit~,
      )
      {
        "items": @json.to_json(p.items.map(e => edge_json(a, e))),
        "total": @json.to_json(p.total),
        "offset": @json.to_json(offset),
      }
    }
    "path" => {
      let path = a.graph.root_path(
        selected_node(s, req),
        max_depth=int_option(req, "max_depth", 1024),
      )
      {
        "reachable": @json.to_json(path is Some(_)),
        "edges": match path {
          Some(p) => @json.to_json(p.map(e => edge_json(a, e)))
          None => Json::null()
        },
      }
    }
    _ => advanced_query(a, req, op, offset, limit)
  }
}

///|
pub fn Inspector::diff(
  self : Inspector,
  before : Int32,
  after : Int32,
  input : String,
) -> Json raise HeapError {
  retention_diff_json(self.get(before), self.get(after), request(input))
}

///|
pub fn success(value : Json) -> String {
  ({ "api_version": 1, "ok": true, "data": value } : Json).stringify()
}

///|
pub fn failure(error : HeapError) -> String {
  let (code, message) = match error {
    Invalid(s) => ("invalid_input", s)
    Limit(s) => ("resource_limit", s)
    NotFound(s) => ("not_found", s)
    Identity(s) => ("identity_conflict", s)
  }
  (
    {
      "api_version": 1,
      "ok": false,
      "error": {
        "code": @json.to_json(code),
        "message": @json.to_json(message),
      },
    } : Json).stringify()
}