///|
priv struct HtmlBudget {
  limit : Int32
  mut used : Int32
  mut failed : Bool
}

///|
fn HtmlBudget::reserve(self : HtmlBudget, units : Int32) -> Bool {
  if units < 0 || units > self.limit - self.used {
    self.failed = true
    return false
  }
  self.used += units
  true
}

///|
fn HtmlBudget::str(self : HtmlBudget, value : String) -> Json {
  if value.length() > (self.limit - self.used) / 6 {
    self.failed = true
    return Json("")
  }
  ignore(self.reserve(value.length() * 6 + 2))
  Json(value)
}

///|
fn HtmlBudget::number(self : HtmlBudget, value : Int32) -> Json {
  ignore(self.reserve(16))
  Json(value)
}

///|
fn HtmlBudget::object(
  self : HtmlBudget,
  fields : ArrayView[(String, Json)],
) -> Json {
  ignore(self.reserve(fields.length() * 32 + 2))
  Json(Map(fields))
}

///|
fn HtmlBudget::array(self : HtmlBudget, values : ArrayView[Json]) -> Json {
  ignore(self.reserve(values.length() + 2))
  Json(values.to_owned())
}

///|
fn description(node : @model.Node, budget : HtmlBudget) -> String {
  let result = StringBuilder()
  if node.properties.bind(p => p.description) is Some(text) {
    if !budget.reserve(text.length() + 1) {
      return ""
    }
    result.write_string(text)
    result.write_char('\n')
  }
  for event in node.markers.events {
    for (key, value) in event.settings {
      if !budget.reserve(key.length() + value.length() + 3) {
        return ""
      }
      result.write_string(key)
      result.write_string(": ")
      result.write_string(value)
      result.write_char('\n')
    }
  }
  result.to_string()
}

///|
fn endpoint_label(
  node : @animation.GraphNode,
  catalog : @animation.Catalog,
) -> String {
  if node.kind == Message &&
    node.occurrence.bind(key => catalog.occurrence(key)) is Some(occurrence) &&
    occurrence.message is Some(message) {
    let label = if message.label.is_empty() {
      message.sender + " → " + message.receiver
    } else {
      message.label
    }
    return "Message \{occurrence.index+1}: \{label}"
  }
  if !node.label.is_empty() {
    return node.label
  }
  node.swimlane_node
  .map(n => n.id)
  .unwrap_or_else(() => {
    node.source_node.bind(n => n.source.source_id).unwrap_or(node.id)
  })
}

///|
fn playback_data(
  scene : @svg.Scene,
  plan : @animation.PlaybackPlan,
  query_limits : @animation.Limits,
  step_limit : Int32,
  budget : HtmlBudget,
) -> Json {
  let steps = []
  for step in plan.steps {
    let groups = step.concurrent_groups
      .iter()
      .map(g => budget.number(g))
      .to_array()
    steps.push(
      budget.object([
        ("reversed", Json(step.reversed)),
        ("ordinal", budget.number(step.ordinal)),
        ("key", budget.str(step.occurrence.key)),
        ("start_ms", budget.str(step.start_ms.to_string())),
        ("duration_ms", budget.str(step.duration_ms.to_string())),
        ("groups", budget.array(groups)),
      ]),
    )
    if budget.failed {
      return Json(null)
    }
  }
  let paths = []
  for binding in scene.paths {
    paths.push(
      budget.object([
        (
          "label",
          budget.str(
            binding.occurrence.swimlane_connection
            .map(c => c.label)
            .unwrap_or(""),
          ),
        ),
        (
          "reversed",
          Json(
            binding.occurrence.swimlane_connection
            .map(c => c.direction == Backward)
            .unwrap_or(false),
          ),
        ),
        ("key", budget.str(binding.occurrence.key)),
        ("id", budget.str(binding.id)),
        ("index", budget.number(binding.occurrence.index)),
        (
          "kind",
          budget.str(
            if binding.occurrence.swimlane_connection is Some(_) {
              "swimlane"
            } else if binding.occurrence.flow is Some(_) {
              "bpmn"
            } else {
              "sequence"
            },
          ),
        ),
        (
          "connection",
          binding.occurrence.swimlane_connection
          .map(c => budget.str(c.id))
          .unwrap_or_else(() => {
            binding.occurrence.flow
            .map(f => budget.str(f.id))
            .unwrap_or(Json(null))
          }),
        ),
      ]),
    )
    if budget.failed {
      return Json(null)
    }
  }
  let nodes = []
  for node in scene.catalog.graph.nodes {
    let kind = match node.kind {
      Bpmn(_) => "bpmn"
      SwimlaneNode | SwimlaneGroup | SwimlaneLane => "swimlane"
      Message => "message"
      _ => "control"
    }
    let detail = node.source_node.map(n => description(n, budget)).unwrap_or("")
    nodes.push(
      budget.object([
        ("primary", Json(node.swimlane_node is Some(_))),
        ("id", budget.str(node.id)),
        ("label", budget.str(endpoint_label(node, scene.catalog))),
        ("kind", budget.str(kind)),
        (
          "occurrence",
          node.occurrence.map(k => budget.str(k)).unwrap_or(Json(null)),
        ),
        ("description", budget.str(detail)),
      ]),
    )
    if budget.failed {
      return Json(null)
    }
  }
  let edges = []
  for edge in scene.catalog.graph.edges {
    let kind = match edge.kind {
      SequenceFlow => "sequence"
      MessageFlow => "message"
      Order => "order"
      SwimlaneFlow => "swimlane"
      Undirected => "undirected"
    }
    edges.push(
      budget.object([
        ("reversed", Json(edge.reversed)),
        ("kind", budget.str(kind)),
        ("source", budget.str(edge.source)),
        ("target", budget.str(edge.target)),
        (
          "occurrence",
          edge.occurrence.map(k => budget.str(k)).unwrap_or(Json(null)),
        ),
      ]),
    )
    if budget.failed {
      return Json(null)
    }
  }
  let outside = []
  match scene.catalog.source {
    Swimlane(view) => {
      for node in view.nodes {
        if scene.catalog.graph.lookup(node.id) is Err(OutsideView(_)) {
          outside.push(budget.str(node.id))
        }
      }
      for group in view.groups {
        if scene.catalog.graph.lookup(group.id) is Err(OutsideView(_)) {
          outside.push(budget.str(group.id))
        }
      }
    }
    _ => ()
  }
  let node_bindings = []
  for binding in scene.nodes {
    let value = match binding.kind {
      Swimlane(id) =>
        budget.object([
          ("endpoint", budget.str(binding.endpoint)),
          ("kind", budget.str("swimlane")),
          ("id", budget.str(id)),
        ])
      Bpmn(id) =>
        budget.object([
          ("endpoint", budget.str(binding.endpoint)),
          ("kind", budget.str("bpmn")),
          ("id", budget.str(id)),
        ])
      Message(index) =>
        budget.object([
          ("endpoint", budget.str(binding.endpoint)),
          ("kind", budget.str("message")),
          ("index", budget.number(index)),
        ])
      Frame(index) =>
        budget.object([
          ("endpoint", budget.str(binding.endpoint)),
          ("kind", budget.str("frame")),
          ("index", budget.number(index)),
        ])
    }
    node_bindings.push(value)
    if budget.failed {
      return Json(null)
    }
  }
  budget.object([
    ("version", budget.number(1)),
    ("step_limit", budget.number(step_limit)),
    ("view_id", budget.str(scene.catalog.view_id)),
    ("kind", budget.str(scene.catalog.source.kind_name())),
    ("canonical", budget.str(scene.canonical_svg)),
    ("accent", budget.str("#2563eb")),
    (
      "plan",
      budget.object([
        (
          "mode",
          budget.str(
            if plan.mode == Illustrative {
              "illustrative"
            } else {
              "finite"
            },
          ),
        ),
        ("duration_ms", budget.str(plan.duration_ms.to_string())),
        ("steps", budget.array(steps)),
      ]),
    ),
    ("paths", budget.array(paths)),
    ("node_bindings", budget.array(node_bindings)),
    ("outside", budget.array(outside)),
    (
      "graph",
      budget.object([
        ("nodes", budget.array(nodes)),
        ("edges", budget.array(edges)),
      ]),
    ),
    (
      "query_limits",
      budget.object([
        ("nodes", budget.number(query_limits.nodes)),
        ("edges", budget.number(query_limits.edges)),
        ("results", budget.number(query_limits.results)),
        ("work", budget.number(query_limits.work)),
      ]),
    ),
  ])
}

///|
fn safe_script_json(data : Json) -> String {
  data
  .stringify()
  .replace_all(old="<", new="\\u003c")
  .replace_all(old=">", new="\\u003e")
  .replace_all(old="&", new="\\u0026")
  .replace_all(old="\u2028", new="\\u2028")
  .replace_all(old="\u2029", new="\\u2029")
}