///|
pub(all) enum AssetBucket {
  BucketSource
  BucketDocument
  BucketConfig
  BucketMedia
  BucketArchive
  BucketGenerated
  BucketRuntime
  BucketOther
} derive(Eq)

///|
pub(all) struct CountRow {
  key : String
  count : Int
  bytes : Int
} derive(Eq)

///|
pub(all) struct AssetCatalog {
  manifest_name : String
  manifest_version : String
  entries : Int
  total_bytes : Int
  directories : Array[CountRow]
  extensions : Array[CountRow]
  kinds : Array[CountRow]
  policies : Array[CountRow]
  tags : Array[CountRow]
  buckets : Array[CountRow]
} derive(Eq)

///|
pub fn AssetBucket::to_wire(self : AssetBucket) -> String {
  match self {
    BucketSource => "source"
    BucketDocument => "document"
    BucketConfig => "config"
    BucketMedia => "media"
    BucketArchive => "archive"
    BucketGenerated => "generated"
    BucketRuntime => "runtime"
    BucketOther => "other"
  }
}

///|
pub fn parse_asset_bucket(raw : String) -> AssetBucket {
  match raw.trim().to_owned().to_lower() {
    "source" => BucketSource
    "document" => BucketDocument
    "config" => BucketConfig
    "media" => BucketMedia
    "archive" => BucketArchive
    "generated" => BucketGenerated
    "runtime" => BucketRuntime
    _ => BucketOther
  }
}

///|
pub fn classify_asset(entry : Asset) -> AssetBucket {
  if entry.policy == Runtime {
    return BucketRuntime
  }
  match path_is_generated_like(entry.path) {
    Ok(true) => return BucketGenerated
    _ => ()
  }
  match entry.kind {
    Image | Audio | Font => return BucketMedia
    Binary =>
      match path_is_archive_like(entry.path) {
        Ok(true) => return BucketArchive
        _ => ()
      }
    _ => ()
  }
  match path_is_source_like(entry.path) {
    Ok(true) => return BucketSource
    _ => ()
  }
  match path_is_config_like(entry.path) {
    Ok(true) => return BucketConfig
    _ => ()
  }
  match path_is_document_like(entry.path) {
    Ok(true) => return BucketDocument
    _ => ()
  }
  match path_is_media_like(entry.path) {
    Ok(true) => return BucketMedia
    _ => ()
  }
  match path_is_archive_like(entry.path) {
    Ok(true) => return BucketArchive
    _ => ()
  }
  BucketOther
}

///|
pub fn asset_directory(entry : Asset) -> String {
  match path_dirname(entry.path) {
    Ok(dirname) => dirname
    Err(_) => "."
  }
}

///|
pub fn asset_extension(entry : Asset) -> String {
  match path_extension(entry.path) {
    Ok(Some(extension)) => extension
    _ => "-"
  }
}

///|
pub fn asset_bucket_name(entry : Asset) -> String {
  classify_asset(entry).to_wire()
}

///|
pub fn catalog_manifest(manifest : Manifest) -> AssetCatalog {
  {
    manifest_name: manifest.name,
    manifest_version: manifest.version,
    entries: manifest.entries.length(),
    total_bytes: catalog_total_bytes(manifest.entries),
    directories: count_by_directory(manifest),
    extensions: count_by_extension(manifest),
    kinds: count_by_kind(manifest),
    policies: count_by_policy(manifest),
    tags: count_by_tag(manifest),
    buckets: count_by_bucket(manifest),
  }
}

///|
pub fn count_by_directory(manifest : Manifest) -> Array[CountRow] {
  count_entries_by(manifest.entries, entry => asset_directory(entry))
}

///|
pub fn count_by_extension(manifest : Manifest) -> Array[CountRow] {
  count_entries_by(manifest.entries, entry => asset_extension(entry))
}

///|
pub fn count_by_kind(manifest : Manifest) -> Array[CountRow] {
  count_entries_by(manifest.entries, entry => entry.kind.to_wire())
}

///|
pub fn count_by_policy(manifest : Manifest) -> Array[CountRow] {
  count_entries_by(manifest.entries, entry => entry.policy.to_wire())
}

///|
pub fn count_by_bucket(manifest : Manifest) -> Array[CountRow] {
  count_entries_by(manifest.entries, entry => classify_asset(entry).to_wire())
}

///|
pub fn count_by_tag(manifest : Manifest) -> Array[CountRow] {
  let keys : Array[String] = []
  let counts : Map[String, Int] = Map([])
  let bytes : Map[String, Int] = Map([])
  for entry in manifest.entries {
    if entry.tags.is_empty() {
      bump_count(keys, counts, bytes, "-", entry.bytes)
    } else {
      for tag in entry.tags {
        bump_count(keys, counts, bytes, tag, entry.bytes)
      }
    }
  }
  rows_from_counts(keys, counts, bytes)
}

///|
pub fn manifest_directories(manifest : Manifest) -> Array[String] {
  row_keys(count_by_directory(manifest))
}

///|
pub fn manifest_extensions(manifest : Manifest) -> Array[String] {
  row_keys(count_by_extension(manifest))
}

///|
pub fn manifest_tags(manifest : Manifest) -> Array[String] {
  row_keys(count_by_tag(manifest))
}

///|
pub fn assets_with_kind(manifest : Manifest, kind : AssetKind) -> Array[Asset] {
  filter_entries(manifest.entries, entry => entry.kind == kind)
}

///|
pub fn assets_with_policy(
  manifest : Manifest,
  policy : CachePolicy,
) -> Array[Asset] {
  filter_entries(manifest.entries, entry => entry.policy == policy)
}

///|
pub fn assets_with_bucket(
  manifest : Manifest,
  bucket : AssetBucket,
) -> Array[Asset] {
  filter_entries(manifest.entries, entry => classify_asset(entry) == bucket)
}

///|
pub fn assets_with_tag(manifest : Manifest, tag : String) -> Array[Asset] {
  let wanted = tag.trim().to_owned().to_lower()
  filter_entries(manifest.entries, entry => entry.tags.contains(wanted))
}

///|
pub fn assets_with_extension(
  manifest : Manifest,
  extension : String,
) -> Array[Asset] {
  let wanted = normalize_extension_query(extension)
  filter_entries(manifest.entries, entry => asset_extension(entry) == wanted)
}

///|
pub fn assets_under_directory(
  manifest : Manifest,
  directory : String,
) -> Array[Asset] {
  let normalized = match normalize_path(directory) {
    Ok(value) => value
    Err(_) => directory.trim().to_owned().replace_all(old="\\", new="/")
  }
  filter_entries(manifest.entries, entry => {
    entry.path == normalized || entry.path.has_prefix(normalized + "/")
  })
}

///|
pub fn assets_larger_than(manifest : Manifest, min_bytes : Int) -> Array[Asset] {
  filter_entries(manifest.entries, entry => entry.bytes > min_bytes)
}

///|
pub fn assets_smaller_than(
  manifest : Manifest,
  max_bytes : Int,
) -> Array[Asset] {
  filter_entries(manifest.entries, entry => entry.bytes < max_bytes)
}

///|
pub fn assets_between_sizes(
  manifest : Manifest,
  min_bytes : Int,
  max_bytes : Int,
) -> Array[Asset] {
  filter_entries(manifest.entries, entry => {
    entry.bytes >= min_bytes && entry.bytes <= max_bytes
  })
}

///|
pub fn largest_assets(manifest : Manifest, limit : Int) -> Array[Asset] {
  let entries = [ for entry in manifest.entries => entry ]
  entries.sort_by((a, b) => b.bytes.compare(a.bytes))
  take_assets(entries, limit)
}

///|
pub fn smallest_assets(manifest : Manifest, limit : Int) -> Array[Asset] {
  let entries = [ for entry in manifest.entries => entry ]
  entries.sort_by((a, b) => a.bytes.compare(b.bytes))
  take_assets(entries, limit)
}

///|
pub fn newest_paths_from_diff(diffs : Array[EntryDiff]) -> Array[String] {
  let paths : Array[String] = []
  for diff in diffs {
    match diff.kind {
      Added => paths.push(diff.path)
      Changed => paths.push(diff.path)
      Removed => ()
    }
  }
  paths.sort()
  paths
}

///|
pub fn removed_paths_from_diff(diffs : Array[EntryDiff]) -> Array[String] {
  let paths : Array[String] = []
  for diff in diffs {
    match diff.kind {
      Removed => paths.push(diff.path)
      _ => ()
    }
  }
  paths.sort()
  paths
}

///|
pub fn changed_paths_from_diff(diffs : Array[EntryDiff]) -> Array[String] {
  let paths : Array[String] = []
  for diff in diffs {
    match diff.kind {
      Changed => paths.push(diff.path)
      _ => ()
    }
  }
  paths.sort()
  paths
}

///|
pub fn catalog_has_path(manifest : Manifest, path : String) -> Bool {
  match normalize_path(path) {
    Ok(normalized) => manifest.entries.any(entry => entry.path == normalized)
    Err(_) => false
  }
}

///|
pub fn catalog_get_path(manifest : Manifest, path : String) -> Asset? {
  match normalize_path(path) {
    Ok(normalized) => {
      for entry in manifest.entries {
        if entry.path == normalized {
          return Some(entry)
        }
      }
      None
    }
    Err(_) => None
  }
}

///|
pub fn catalog_fingerprint_index(manifest : Manifest) -> Array[CountRow] {
  count_entries_by(manifest.entries, entry => entry.fingerprint)
}

///|
pub fn duplicate_fingerprint_rows(manifest : Manifest) -> Array[CountRow] {
  let rows = catalog_fingerprint_index(manifest)
  filter_rows(rows, row => row.count > 1)
}

///|
pub fn unique_asset_count(manifest : Manifest) -> Int {
  let rows = catalog_fingerprint_index(manifest)
  rows.length()
}

///|
pub fn duplicate_asset_count(manifest : Manifest) -> Int {
  let mut total = 0
  for row in duplicate_fingerprint_rows(manifest) {
    total = total + row.count
  }
  total
}

///|
pub fn render_count_rows(rows : Array[CountRow]) -> String {
  if rows.is_empty() {
    return "(none)\n"
  }
  let out = StringBuilder()
  for row in rows {
    out.write_string(row.key)
    out.write_string(" ")
    out.write_string(row.count.to_string())
    out.write_string(" ")
    out.write_string(row.bytes.to_string())
    out.write_string("\n")
  }
  out.to_string()
}

///|
pub fn render_catalog(catalog : AssetCatalog) -> String {
  let out = StringBuilder()
  out.write_string("catalog ")
  out.write_string(catalog.manifest_name)
  out.write_string("@")
  out.write_string(catalog.manifest_version)
  out.write_string("\nentries ")
  out.write_string(catalog.entries.to_string())
  out.write_string("\nbytes ")
  out.write_string(catalog.total_bytes.to_string())
  out.write_string("\n\n[buckets]\n")
  out.write_string(render_count_rows(catalog.buckets))
  out.write_string("\n[extensions]\n")
  out.write_string(render_count_rows(catalog.extensions))
  out.write_string("\n[directories]\n")
  out.write_string(render_count_rows(catalog.directories))
  out.to_string()
}

///|
fn count_entries_by(
  entries : Array[Asset],
  key_of : (Asset) -> String,
) -> Array[CountRow] {
  let keys : Array[String] = []
  let counts : Map[String, Int] = Map([])
  let bytes : Map[String, Int] = Map([])
  for entry in entries {
    bump_count(keys, counts, bytes, key_of(entry), entry.bytes)
  }
  rows_from_counts(keys, counts, bytes)
}

///|
fn bump_count(
  keys : Array[String],
  counts : Map[String, Int],
  bytes : Map[String, Int],
  key : String,
  entry_bytes : Int,
) -> Unit {
  if !counts.contains(key) {
    keys.push(key)
    counts.set(key, 1)
    bytes.set(key, entry_bytes)
  } else {
    let count = match counts.get(key) {
      Some(value) => value
      None => 0
    }
    let total = match bytes.get(key) {
      Some(value) => value
      None => 0
    }
    counts.set(key, count + 1)
    bytes.set(key, total + entry_bytes)
  }
}

///|
fn rows_from_counts(
  keys : Array[String],
  counts : Map[String, Int],
  bytes : Map[String, Int],
) -> Array[CountRow] {
  let rows : Array[CountRow] = []
  for key in keys {
    let count = match counts.get(key) {
      Some(value) => value
      None => 0
    }
    let total = match bytes.get(key) {
      Some(value) => value
      None => 0
    }
    rows.push({ key, count, bytes: total })
  }
  rows.sort_by((a, b) => {
    if a.count == b.count {
      a.key.compare(b.key)
    } else {
      b.count.compare(a.count)
    }
  })
  rows
}

///|
fn row_keys(rows : Array[CountRow]) -> Array[String] {
  let keys : Array[String] = []
  for row in rows {
    keys.push(row.key)
  }
  keys
}

///|
fn filter_entries(
  entries : Array[Asset],
  keep : (Asset) -> Bool,
) -> Array[Asset] {
  let result : Array[Asset] = []
  for entry in entries {
    if keep(entry) {
      result.push(entry)
    }
  }
  result
}

///|
fn filter_rows(
  rows : Array[CountRow],
  keep : (CountRow) -> Bool,
) -> Array[CountRow] {
  let result : Array[CountRow] = []
  for row in rows {
    if keep(row) {
      result.push(row)
    }
  }
  result
}

///|
fn take_assets(entries : Array[Asset], limit : Int) -> Array[Asset] {
  let result : Array[Asset] = []
  if limit <= 0 {
    return result
  }
  let mut taken = 0
  for entry in entries {
    if taken >= limit {
      return result
    }
    result.push(entry)
    taken = taken + 1
  }
  result
}

///|
fn normalize_extension_query(extension : String) -> String {
  let trimmed = extension.trim().to_owned().to_lower()
  match trimmed.strip_prefix(".") {
    Some(rest) => if rest == "" { "-" } else { rest.to_owned() }
    None => if trimmed == "" { "-" } else { trimmed }
  }
}

///|
fn catalog_total_bytes(entries : Array[Asset]) -> Int {
  let mut total = 0
  for entry in entries {
    total = total + entry.bytes
  }
  total
}