///|
/// User controls become visible text before DOT escaping. Backslashes are then
/// doubled, including escString substitutions such as literal \N and \n.
/// This deliberately does not change the legacy whole-module DOT renderer.
fn dependencies_dot_quote(text : String) -> String {
  let visible = StringBuilder()
  for ch in text.iter() {
    let n = ch.to_int()
    if n < 0x20 || (n >= 0x7f && n <= 0x9f) || n == 0x2028 || n == 0x2029 {
      visible.write_string("\\u")
      for shift in [12, 8, 4, 0] {
        visible.write_string(hex_digit((n >> shift) & 15))
      }
    } else {
      visible.write_string(ch.to_string())
    }
  }
  quote(visible.to_string(), dot_escape)
}

///|
/// Only these program-owned separators introduce Graphviz layout newlines.
fn dependencies_dot_label(lines : Array[String]) -> String {
  let escaped = lines.map(fn(line) {
    let quoted = dependencies_dot_quote(line)
    quoted[1:quoted.length() - 1].to_owned()
  })
  "\"" + escaped.join("\\n") + "\""
}

///|
/// Render complete forward-closure evidence without analysing or mutating it.
/// Top is deliberately absent. Parallel edges remain separate DOT statements.
pub fn render_function_dependencies_dot(
  report : FunctionDependenciesReport,
) -> String {
  let out = StringBuilder()
  out.write_string("digraph moonsize_dependencies {\n  rankdir=LR;\n")
  let title = [
    "Query target #\{report.target.index}; nodes \{report.nodes.length()}; edges \{report.edges.length()}",
    "Direct: solid; reference: dashed; indirect candidate: dotted. Edge labels are file offsets, not call-site counts.",
    "Current reference model, not execution or removal savings. No global dead/live classification. Layout cost is not bounded.",
  ]
  if !report.graph_complete {
    title.push(
      "WARNING: incomplete graph; diagnostics \{report.diagnostics.length()}",
    )
  }
  if !report.graph_warning.is_empty() {
    title.push(report.graph_warning)
  }
  out.write_string(
    "  graph [labelloc=\"t\", label=" + dependencies_dot_label(title) + "];\n",
  )
  for
    (key, value) in [
      ("input_sha256", report.sha256),
      ("file_size", report.file_size.to_string()),
      ("target_index", report.target.index.to_string()),
      ("graph_complete", report.graph_complete.to_string()),
      ("unresolved_count", report.unresolved_count.to_string()),
      ("modeling_scope", report.modeling_scope),
      ("graph_warning", report.graph_warning),
      (
        "diagnostics_json",
        diagnostic_records_json(report.diagnostics).stringify(),
      ),
    ] {
    out.write_string("  " + key + "=" + dependencies_dot_quote(value) + ";\n")
  }
  let nodes = report.nodes.copy()
  nodes.sort_by(fn(a, b) { a.node.index - b.node.index })
  for entry in nodes {
    let node = entry.node
    let label = [
      "#\{node.index}: " + node.name,
      if node.imported {
        "imported; encoded size not measured"
      } else {
        "local; encoded size " +
        (match node.encoded_size {
          Some(n) => "\{n} B"
          None => "not measured"
        })
      },
      "BFS depth \{entry.depth}",
    ]
    let target = node.index == report.target.index
    if target {
      label.push("query target")
    }
    out.write_string(
      "  fn_\{node.index} [shape=" +
      (if node.imported { "ellipse" } else { "box" }) +
      ", label=" +
      dependencies_dot_label(label) +
      (if target {
        ", penwidth=2, style=filled, fillcolor=\"lightyellow\""
      } else {
        ""
      }) +
      "];\n",
    )
  }
  let edges = report.edges.copy()
  edges.sort_by(fn(a, b) {
    if a.caller != b.caller {
      a.caller - b.caller
    } else if a.offset != b.offset {
      a.offset - b.offset
    } else if a.callee != b.callee {
      a.callee - b.callee
    } else {
      path_edge_rank(a.kind) - path_edge_rank(b.kind)
    }
  })
  for edge in edges {
    let (kind, style) = match edge.kind {
      Direct => ("direct", "solid")
      Reference => ("reference", "dashed")
      Indirect => ("indirect candidate", "dotted")
    }
    out.write_string(
      "  fn_\{edge.caller} -> fn_\{edge.callee} [style=" +
      style +
      ", label=" +
      dependencies_dot_quote(kind + "; file offset \{edge.offset}") +
      "];\n",
    )
  }
  out.write_string("}\n")
  out.to_string()
}