///|
/// How many nodes of one JSON type the document holds.
pub(all) struct TypeCount {
  kind : String
  count : Int
} derive(ToJson)

///|
/// Name the `to_json` method that `derive(ToJson)` provides.
///
/// The implicit promotion of an impl's methods to regular methods is deprecated
/// as of moon 0.1.20260920, and `--deny-warn` refuses it. Spelling the promotion
/// out changes nothing about the type or the JSON it produces: `derive` still
/// writes the method, and this says which one it is.
pub extend TypeCount with ToJson::{to_json}

///|
/// How many keys one top-level field contains, counting nested ones.
pub(all) struct KeyCount {
  name : String
  count : Int
} derive(ToJson)

///|
/// As for `TypeCount`: the promotion `derive(ToJson)` makes, spelled out.
pub extend KeyCount with ToJson::{to_json}

///|
/// How many nodes sit at one nesting depth.
pub(all) struct DepthCount {
  depth : Int
  count : Int
} derive(ToJson)

///|
/// As for `TypeCount`: the promotion `derive(ToJson)` makes, spelled out.
pub extend DepthCount with ToJson::{to_json}

///|
/// Summary statistics for one JSON document.
///
/// This is the shape written by `--json-out` and read by the frontend, so the
/// field names are part of the tool's interface rather than an internal detail.
pub(all) struct JsonStats {
  total_keys : Int
  total_nodes : Int
  max_depth : Int
  type_counts : Array[TypeCount]
  key_counts : Array[KeyCount]
  depth_counts : Array[DepthCount]
} derive(ToJson)

///|
/// As for `TypeCount`: the promotion `derive(ToJson)` makes, spelled out.
pub extend JsonStats with ToJson::{to_json}

///|
/// Mutable accumulator threaded through the statistics walk.
///
/// The record holds mutable fields, so it is passed by reference and every
/// `walk_stats` call contributes to the same totals.
priv struct StatsBuilder {
  mut total_keys : Int
  mut total_nodes : Int
  mut max_depth : Int
  mut nulls : Int
  mut booleans : Int
  mut numbers : Int
  mut strings : Int
  mut arrays : Int
  mut objects : Int
  depth_counts : Array[Int]
}

///|
/// A fresh accumulator, with the root depth already present.
fn StatsBuilder::new() -> StatsBuilder {
  {
    total_keys: 0,
    total_nodes: 0,
    max_depth: 0,
    nulls: 0,
    booleans: 0,
    numbers: 0,
    strings: 0,
    arrays: 0,
    objects: 0,
    depth_counts: [0],
  }
}

///|
/// Count one more node at `depth`, growing the table if this is a new depth.
fn record_depth(builder : StatsBuilder, depth : Int) -> Unit {
  while builder.depth_counts.length() <= depth {
    builder.depth_counts.push(0)
  }
  builder.depth_counts[depth] = builder.depth_counts[depth] + 1
}

///|
/// Record `json` at `depth` and recurse into its children.
///
/// Depths are 1-based, so a scalar document reports a maximum depth of 1.
fn walk_stats(builder : StatsBuilder, json : @pjson.Json, depth : Int) -> Unit {
  builder.total_nodes = builder.total_nodes + 1
  if depth > builder.max_depth {
    builder.max_depth = depth
  }
  record_depth(builder, depth)
  match json {
    Null => builder.nulls = builder.nulls + 1
    Bool(_) => builder.booleans = builder.booleans + 1
    Number(_) => builder.numbers = builder.numbers + 1
    Text(_) => builder.strings = builder.strings + 1
    Array(items~) => {
      builder.arrays = builder.arrays + 1
      for item in items {
        walk_stats(builder, item, depth + 1)
      }
    }
    Object(members~) => {
      builder.objects = builder.objects + 1
      builder.total_keys = builder.total_keys + members.length()
      for entry in members {
        let (_, value) = entry
        walk_stats(builder, value, depth + 1)
      }
    }
  }
}

///|
/// Count every object member inside `json`, at any depth.
fn count_keys(json : @pjson.Json) -> Int {
  match json {
    Object(members~) => {
      let mut total = members.length()
      for entry in members {
        let (_, value) = entry
        total = total + count_keys(value)
      }
      total
    }
    Array(items~) => {
      let mut total = 0
      for item in items {
        total = total + count_keys(item)
      }
      total
    }
    _ => 0
  }
}

///|
/// The number of keys inside each top-level field.
///
/// Charting these counts shows which parts of a document carry the most
/// structure, which is usually where a refactor pays off. A document whose root
/// is not an object is reported as a single `` entry.
fn top_level_key_counts(json : @pjson.Json) -> Array[KeyCount] {
  match json {
    Object(members~) => {
      let counts = []
      for entry in members {
        let (name, value) = entry
        counts.push({ name, count: count_keys(value), })
      }
      counts
    }
    _ => [{ name: "", count: count_keys(json), }]
  }
}

///|
/// Compute the statistics of `json`.
pub fn JsonStats::of(json : @pjson.Json) -> JsonStats {
  let builder = StatsBuilder::new()
  walk_stats(builder, json, 1)
  let types : Array[TypeCount] = [
    { kind: "null", count: builder.nulls, },
    { kind: "boolean", count: builder.booleans, },
    { kind: "number", count: builder.numbers, },
    { kind: "string", count: builder.strings, },
    { kind: "array", count: builder.arrays, },
    { kind: "object", count: builder.objects, },
  ]
  let depths : Array[DepthCount] = []
  for depth in 1..<(builder.max_depth + 1) {
    depths.push({ depth, count: builder.depth_counts[depth], })
  }
  {
    total_keys: builder.total_keys,
    total_nodes: builder.total_nodes,
    max_depth: builder.max_depth,
    type_counts: types,
    key_counts: top_level_key_counts(json),
    depth_counts: depths,
  }
}

///|
/// Render the statistics as indented JSON, ready for the frontend to read.
pub fn JsonStats::to_json_text(self : JsonStats) -> String {
  @json.to_json(self).stringify(indent=2)
}

///|
/// Render the statistics as the two lines `--stats` prints:
///
/// ```
/// keys: 6  nodes: 9  depth: 4
/// type counts: object=3 array=1 string=3 number=2
/// ```
///
/// A type that does not occur is left out rather than printed as `kind=0`. The
/// line is a summary of what the document holds, and the absence of a type says
/// that in less room than a row of zeroes would.
///
/// The counts that remain keep the canonical order of `type_counts` — the order
/// `to_json_text` writes and the dashboard charts — rather than being sorted by
/// size, so a reader comparing two documents can follow one type with their eye
/// down both lines. It also means the two lines agree with the statistics file
/// entry for entry: `--stats` is the same numbers in less space.
pub fn JsonStats::to_text(self : JsonStats) -> String {
  let buf = StringBuilder()
  buf.write_string("keys: " + self.total_keys.to_string())
  buf.write_string("  nodes: " + self.total_nodes.to_string())
  buf.write_string("  depth: " + self.max_depth.to_string())
  buf.write_char('\n')
  buf.write_string("type counts:")
  for entry in self.type_counts {
    if entry.count > 0 {
      buf.write_string(" " + entry.kind + "=" + entry.count.to_string())
    }
  }
  buf.to_string()
}

///|
/// Write the statistics of `json` to `path`.
pub async fn write_stats_file(
  path : String,
  json : @pjson.Json,
) -> Result[Unit, String] {
  let text = JsonStats::of(json).to_json_text() + "\n"
  try {
    @fs.write_file(path, text)
    Ok(())
  } catch {
    error => Err(describe_io_error(error))
  }
}