///|
pub struct FunctionDependentNode {
  node : FunctionPathNode
  depth : Int
  next_edge : CallEdge?
}

///|
pub struct FunctionDependentsReport {
  target : FunctionPathNode
  file_size : Int
  sha256 : String
  function_count : Int
  local_function_count : Int
  imported_function_count : Int
  nodes : Array[FunctionDependentNode]
  edges : Array[CallEdge]
  reaching_function_count : Int
  other_function_count : Int
  reaching_local_count : Int
  reaching_imported_count : Int
  dependent_function_count : Int
  local_encoded_bytes : Int
  reaching_local_encoded_bytes : Int
  other_local_encoded_bytes : Int
  target_local_encoded_bytes : Int
  dependent_local_encoded_bytes : Int
  edge_count : Int
  direct_edges : Int
  reference_edges : Int
  indirect_edges : Int
  graph_complete : Bool
  unresolved_count : Int
  diagnostics : Array[GraphDiagnostic]
  graph_warning : String
  modeling_scope : String
}

///|
pub enum FunctionDependentsError {
  DependentsInputError(WasmError)
  InvalidDependentsTarget(Int, Int)
  InvalidDependentsEvidence(Int, Int, String)
}

///|
pub fn describe_function_dependents_error(
  error : FunctionDependentsError,
) -> String {
  match error {
    DependentsInputError(error) => describe_error(error)
    InvalidDependentsTarget(index, count) =>
      "dependent target function index \{index} is outside the function index space of \{count}"
    InvalidDependentsEvidence(index, offset, reason) =>
      "invalid dependent graph evidence for function \{index} at file offset \{offset}: " +
      reason
  }
}

///|
fn dependents_scope() -> String {
  "Reverse transitive closure to any valid full function index in the current reference model, including the target itself. Sources need not be exported or reachable from model roots. " +
  "Direct calls, references taken by ref.func and static indirect candidates count as model steps, not actual execution, runtime heat, source-change impact, necessary retention or deletion benefit. Unmodeled call_ref targets remain diagnostics without guessed edges. " +
  "Depth is the minimum number of model edges to the target, not runtime stack depth. Each node keeps one deterministic first-discovery next_edge toward the target, in original caller -> callee direction. No full paths are copied. Target depth is zero with null next_edge. " +
  "Complete internal edges retain parallel sites, kinds, candidates, self-loops, back and cross edges, separately from the next-edge tree. Edges to nodes outside the reverse closure are excluded. " +
  "Local encoded bytes count each full function index once, including the body length prefix but excluding code header/count and type/data/name/other sections. An import's encoded_size remains null; target_local_encoded_bytes=0 for an imported target is only its local-file contribution, not a measured host body or cost. " +
  "Other functions only means functions with no modeled path to this target, not dead code. Source closures for different targets can overlap and their bytes cannot be added as module size. No source modification impact, retained, recompile or gzip savings are computed. " +
  "Diagnostics and graph_complete describe the entire module, including sites outside this closure. Top limits source text rows, not traversal cost or complete JSON; complete evidence memory grows with the graph. To inspect actual opcodes, use edge offsets separately with --locate-offset --locate-decode. " +
  graph_modeling_scope()
}

///|
/// Validate the whole graph, then one reverse head-index BFS. Evidence is never reduced
/// to adjacency sets or tree edges. Every edge keeps caller -> callee direction. No per-node path copies or instruction decode.
fn dependents_closure(
  inventory : Array[FunctionPathNode],
  edges : Array[CallEdge],
  target : Int,
  file_size : Int,
) -> Result[
  (Array[FunctionDependentNode], Array[CallEdge]),
  FunctionDependentsError,
] {
  let count = inventory.length()
  if target < 0 || target >= count {
    return Err(InvalidDependentsTarget(target, count))
  }
  let adjacency : Array[Array[CallEdge]] = Array::makei(count, fn(_) { [] })
  for edge in edges {
    for index in [edge.caller, edge.callee] {
      if index < 0 || index >= count {
        return Err(
          InvalidDependentsEvidence(
            index,
            edge.offset,
            "index outside function inventory",
          ),
        )
      }
    }
    if inventory[edge.caller].imported {
      return Err(
        InvalidDependentsEvidence(
          edge.caller,
          edge.offset,
          "imported caller has no local body",
        ),
      )
    }
    if edge.offset < 0 || edge.offset >= file_size {
      return Err(
        InvalidDependentsEvidence(
          edge.caller,
          edge.offset,
          "instruction offset outside input",
        ),
      )
    }
    adjacency[edge.callee].push(edge)
  }
  for incoming in adjacency {
    incoming.sort_by(fn(a, b) {
      if a.caller != b.caller {
        return a.caller - b.caller
      }
      let kind = path_edge_rank(a.kind) - path_edge_rank(b.kind)
      if kind != 0 {
        kind
      } else {
        a.offset - b.offset
      }
    })
  }
  let depth : Array[Int] = Array::make(count, -1)
  let next_edge : Array[CallEdge?] = Array::make(count, None)
  let queue = [target]
  depth[target] = 0
  let mut head = 0
  while head < queue.length() {
    let callee = queue[head]
    head += 1
    for edge in adjacency[callee] {
      if depth[edge.caller] >= 0 {
        continue
      }
      depth[edge.caller] = depth[callee] + 1
      next_edge[edge.caller] = Some(edge)
      queue.push(edge.caller)
    }
  }
  let closure_edges : Array[CallEdge] = []
  for edge in edges {
    if depth[edge.callee] < 0 {
      continue
    }
    if depth[edge.caller] < 0 {
      return Err(
        InvalidDependentsEvidence(
          edge.caller,
          edge.offset,
          "caller escapes computed reverse closure",
        ),
      )
    }
    closure_edges.push(edge)
  }
  closure_edges.sort_by(fn(a, b) {
    if a.caller != b.caller {
      return a.caller - b.caller
    }
    if a.offset != b.offset {
      return a.offset - b.offset
    }
    if a.callee != b.callee {
      return a.callee - b.callee
    }
    path_edge_rank(a.kind) - path_edge_rank(b.kind)
  })
  let nodes = queue.map(fn(index) {
    FunctionDependentNode::{
      node: inventory[index],
      depth: depth[index],
      next_edge: next_edge[index],
    }
  })
  nodes.sort_by(fn(a, b) {
    if a.depth != b.depth {
      a.depth - b.depth
    } else {
      a.node.index - b.node.index
    }
  })
  Ok((nodes, closure_edges))
}

///|
/// One inventory and one build(data), not analyze, retained, SCC or callers.
/// Existing inventory/build parsing is reused; this is not a parse-once claim.
pub fn function_dependents(
  data : Bytes,
  target_index : Int,
) -> Result[FunctionDependentsReport, FunctionDependentsError] {
  let parsed = match parse_module(data) {
    Ok(value) => value
    Err(error) => return Err(DependentsInputError(error))
  }
  let inventory = match path_inventory(data, parsed) {
    Ok(value) => value
    Err(error) => return Err(DependentsInputError(error))
  }
  if target_index < 0 || target_index >= inventory.length() {
    return Err(InvalidDependentsTarget(target_index, inventory.length()))
  }
  let graph = match build(data) {
    Ok(value) => value
    Err(error) => return Err(DependentsInputError(error))
  }
  let (nodes, edges) = match
    dependents_closure(inventory, graph.edges, target_index, data.length()) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let mut imports = 0
  let mut local_bytes = 0
  for node in inventory {
    if node.imported {
      imports += 1
    } else {
      local_bytes += node.encoded_size.unwrap_or(0)
    }
  }
  let mut reached_imports = 0
  let mut reached_bytes = 0
  for entry in nodes {
    if entry.node.imported {
      reached_imports += 1
    } else {
      reached_bytes += entry.node.encoded_size.unwrap_or(0)
    }
  }
  let mut direct = 0
  let mut reference = 0
  let mut indirect = 0
  for edge in edges {
    match edge.kind {
      Direct => direct += 1
      Reference => reference += 1
      Indirect => indirect += 1
    }
  }
  let target = inventory[target_index]
  let target_bytes = target.encoded_size.unwrap_or(0)
  Ok({
    target,
    file_size: data.length(),
    sha256: experiment_sha256(data),
    function_count: inventory.length(),
    local_function_count: inventory.length() - imports,
    imported_function_count: imports,
    nodes,
    edges,
    reaching_function_count: nodes.length(),
    other_function_count: inventory.length() - nodes.length(),
    reaching_local_count: nodes.length() - reached_imports,
    reaching_imported_count: reached_imports,
    dependent_function_count: nodes.length() - 1,
    local_encoded_bytes: local_bytes,
    reaching_local_encoded_bytes: reached_bytes,
    other_local_encoded_bytes: local_bytes - reached_bytes,
    target_local_encoded_bytes: target_bytes,
    dependent_local_encoded_bytes: reached_bytes - target_bytes,
    edge_count: edges.length(),
    direct_edges: direct,
    reference_edges: reference,
    indirect_edges: indirect,
    graph_complete: is_graph_complete(graph),
    unresolved_count: graph.unresolved_indirect.length(),
    diagnostics: sorted_diagnostics(graph),
    graph_warning: render_graph_warning(graph),
    modeling_scope: dependents_scope(),
  })
}