///|
/// Dimension used when explaining how a directory's contents changed.
pub(all) enum CompositionDimension {
  Category
  Extension
} derive(Eq, Debug, ToJson, FromJson)

///|
/// Change in one file category or extension between two snapshots.
pub(all) struct CompositionChange {
  name : String
  before_bytes : Int64
  after_bytes : Int64
  byte_delta : Int64
  before_files : Int
  after_files : Int
  file_delta : Int
  share_before_per_mille : Int
  share_after_per_mille : Int
} derive(Eq, Debug, ToJson, FromJson)

///|
/// Complete, serializable description of storage composition drift.
pub(all) struct CompositionDrift {
  dimension : CompositionDimension
  before_total_bytes : Int64
  after_total_bytes : Int64
  net_bytes : Int64
  changes : Array[CompositionChange]
} derive(Eq, Debug, ToJson, FromJson)

///|
pub fn composition_dimension_from_name(
  name : String,
) -> CompositionDimension raise {
  match name.trim().to_lower() {
    "category" | "categories" | "type" | "types" => Category
    "extension" | "extensions" | "ext" => Extension
    value => raise Failure("Unknown composition dimension: " + value.to_owned())
  }
}

///|
pub fn composition_dimension_name(dimension : CompositionDimension) -> String {
  match dimension {
    Category => "category"
    Extension => "extension"
  }
}

///|
fn composition_stats(
  snapshot : Snapshot,
  dimension : CompositionDimension,
) -> Array[CategoryStat] {
  match dimension {
    Category => category_stats(snapshot)
    Extension => extension_stats(snapshot)
  }
}

///|
fn stats_by_name(stats : Array[CategoryStat]) -> Map[String, CategoryStat] {
  let result : Map[String, CategoryStat] = Map([])
  for stat in stats {
    result[stat.name] = stat
  }
  result
}

///|
fn per_mille(value : Int64, total : Int64) -> Int {
  if total <= 0L {
    0
  } else {
    (value * 1000L / total).to_int()
  }
}

///|
fn absolute_bytes(value : Int64) -> Int64 {
  if value < 0L {
    -value
  } else {
    value
  }
}

///|
/// Compare two snapshots by category or file extension. Entries that disappear
/// remain visible with an after-value of zero, so reports never hide removals.
pub fn compare_composition(
  before : Snapshot,
  after : Snapshot,
  dimension : CompositionDimension,
) -> CompositionDrift {
  let before_total = before.total_size()
  let after_total = after.total_size()
  let before_map = stats_by_name(composition_stats(before, dimension))
  let after_map = stats_by_name(composition_stats(after, dimension))
  let names : Map[String, Bool] = Map([])
  for name, _ in before_map {
    names[name] = true
  }
  for name, _ in after_map {
    names[name] = true
  }
  let changes : Array[CompositionChange] = []
  for name, _ in names {
    let old = before_map
      .get(name)
      .unwrap_or(CategoryStat::{ name, size: 0L, files: 0, })
    let current = after_map
      .get(name)
      .unwrap_or(CategoryStat::{ name, size: 0L, files: 0, })
    changes.push(CompositionChange::{
      name,
      before_bytes: old.size,
      after_bytes: current.size,
      byte_delta: current.size - old.size,
      before_files: old.files,
      after_files: current.files,
      file_delta: current.files - old.files,
      share_before_per_mille: per_mille(old.size, before_total),
      share_after_per_mille: per_mille(current.size, after_total),
    })
  }
  changes.sort_by(fn(a, b) {
    let left = absolute_bytes(a.byte_delta)
    let right = absolute_bytes(b.byte_delta)
    if left > right {
      -1
    } else if left < right {
      1
    } else if a.name < b.name {
      -1
    } else if a.name > b.name {
      1
    } else {
      0
    }
  })
  CompositionDrift::{
    dimension,
    before_total_bytes: before_total,
    after_total_bytes: after_total,
    net_bytes: after_total - before_total,
    changes,
  }
}

///|
fn signed_count(value : Int) -> String {
  if value > 0 {
    "+" + value.to_string()
  } else {
    value.to_string()
  }
}

///|
fn percentage_text(per_mille_value : Int) -> String {
  let whole = per_mille_value / 10
  let fraction = per_mille_value % 10
  whole.to_string() + "." + fraction.to_string() + "%"
}

///|
pub fn render_composition_drift(
  drift : CompositionDrift,
  limit? : Int = 20,
) -> String {
  let lines : Array[String] = [
    "MoonDisk composition drift",
    "Dimension: " + composition_dimension_name(drift.dimension),
    "Before: " + format_bytes(drift.before_total_bytes),
    "After: " + format_bytes(drift.after_total_bytes),
    "Net change: " + format_bytes(drift.net_bytes),
    "",
    pad_right("Bucket", 28) +
    pad_left("Before", 12) +
    pad_left("After", 12) +
    pad_left("Delta", 12) +
    pad_left("Files", 9) +
    pad_left("Share", 16),
  ]
  let count = if limit < drift.changes.length() {
    limit
  } else {
    drift.changes.length()
  }
  for index = 0; index < count; index = index + 1 {
    let item = drift.changes[index]
    let share = percentage_text(item.share_before_per_mille) +
      "→" +
      percentage_text(item.share_after_per_mille)
    lines.push(
      pad_right(item.name, 28) +
      pad_left(format_bytes(item.before_bytes), 12) +
      pad_left(format_bytes(item.after_bytes), 12) +
      pad_left(format_bytes(item.byte_delta), 12) +
      pad_left(signed_count(item.file_delta), 9) +
      pad_left(share, 16),
    )
  }
  if count == 0 {
    lines.push("No file composition data is available.")
  }
  lines.join("\n")
}

///|
pub fn composition_drift_to_json(drift : CompositionDrift) -> String {
  ToJson::to_json(drift).stringify(indent=2)
}