///|
pub(all) struct StorageBudget {
  id : String
  path : String
  maximum_bytes : Int64
  warning_percent : Int
  description : String
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) struct BudgetConfig {
  format_version : Int
  budgets : Array[StorageBudget]
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) enum BudgetStatus {
  WithinBudget
  NearLimit
  OverBudget
  BudgetPathMissing
} derive(Eq, Debug, ToJson, FromJson)

///|
/// Controls when a budget evaluation should fail an automated check.
pub(all) enum BudgetFailureThreshold {
  NeverFail
  FailOnWarning
  FailOnExceeded
} derive(Eq, Debug)

///|
pub(all) struct BudgetResult {
  id : String
  path : String
  maximum_bytes : Int64
  actual_bytes : Int64
  remaining_bytes : Int64
  usage_tenths : Int
  status : BudgetStatus
  description : String
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) enum BudgetConfigIssueKind {
  EmptyBudgetId
  DuplicateBudgetId
  InvalidBudgetPath
  InvalidMaximum
  InvalidWarningPercent
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) struct BudgetConfigIssue {
  kind : BudgetConfigIssueKind
  budget_id : String
  message : String
} derive(Eq, Debug, ToJson, FromJson)

///|
pub fn default_budget_config() -> BudgetConfig {
  BudgetConfig::{
    format_version: 1,
    budgets: [
      StorageBudget::{
        id: "moonbit-build",
        path: "_build",
        maximum_bytes: 1024L * 1024L * 1024L,
        warning_percent: 80,
        description: "Keep generated MoonBit build output below one GiB.",
      },
      StorageBudget::{
        id: "repository-total",
        path: "",
        maximum_bytes: 2L * 1024L * 1024L * 1024L,
        warning_percent: 80,
        description: "Track the total size of the scanned repository.",
      },
    ],
  }
}

///|
fn valid_budget_path(path : String) -> Bool {
  path == "" ||
  (
    normalize_relative_path(path) == path &&
    !path.has_prefix("/") &&
    !path.has_prefix("\\") &&
    !path.contains(":")
  )
}

///|
pub fn validate_budget_config(
  config : BudgetConfig,
) -> Array[BudgetConfigIssue] {
  let issues : Array[BudgetConfigIssue] = []
  let ids : Map[String, Bool] = Map([])
  for budget in config.budgets {
    if budget.id.trim().is_empty() {
      issues.push(BudgetConfigIssue::{
        kind: EmptyBudgetId,
        budget_id: budget.id,
        message: "Every storage budget needs a non-empty id.",
      })
    } else if ids.get(budget.id) is Some(_) {
      issues.push(BudgetConfigIssue::{
        kind: DuplicateBudgetId,
        budget_id: budget.id,
        message: "Storage budget ids must be unique.",
      })
    } else {
      ids[budget.id] = true
    }
    if !valid_budget_path(budget.path) {
      issues.push(BudgetConfigIssue::{
        kind: InvalidBudgetPath,
        budget_id: budget.id,
        message: "Budget paths must be normalized and relative to the scan root.",
      })
    }
    if budget.maximum_bytes <= 0L {
      issues.push(BudgetConfigIssue::{
        kind: InvalidMaximum,
        budget_id: budget.id,
        message: "Maximum bytes must be greater than zero.",
      })
    }
    if budget.warning_percent < 1 || budget.warning_percent > 100 {
      issues.push(BudgetConfigIssue::{
        kind: InvalidWarningPercent,
        budget_id: budget.id,
        message: "Warning percent must be between 1 and 100.",
      })
    }
  }
  issues
}

///|
fn budget_observed_size(snapshot : Snapshot, path : String) -> Int64? {
  if path == "" {
    return Some(snapshot.total_size())
  }
  match snapshot.find(path) {
    Some(entry) if entry.is_file() => Some(entry.size)
    Some(entry) if entry.is_dir() =>
      match stat_for_directory(snapshot, entry.path) {
        Some(stat) => Some(stat.recursive_size)
        None => Some(0L)
      }
    _ => None
  }
}

///|
pub fn evaluate_budget(
  snapshot : Snapshot,
  budget : StorageBudget,
) -> BudgetResult {
  match budget_observed_size(snapshot, budget.path) {
    None =>
      BudgetResult::{
        id: budget.id,
        path: budget.path,
        maximum_bytes: budget.maximum_bytes,
        actual_bytes: 0L,
        remaining_bytes: budget.maximum_bytes,
        usage_tenths: 0,
        status: BudgetPathMissing,
        description: budget.description,
      }
    Some(actual) => {
      let tenths = (actual * 1000L / budget.maximum_bytes).to_int()
      let status = if actual > budget.maximum_bytes {
        OverBudget
      } else if tenths >= budget.warning_percent * 10 {
        NearLimit
      } else {
        WithinBudget
      }
      BudgetResult::{
        id: budget.id,
        path: budget.path,
        maximum_bytes: budget.maximum_bytes,
        actual_bytes: actual,
        remaining_bytes: budget.maximum_bytes - actual,
        usage_tenths: tenths,
        status,
        description: budget.description,
      }
    }
  }
}

///|
pub fn evaluate_budgets(
  snapshot : Snapshot,
  config : BudgetConfig,
) -> Array[BudgetResult] {
  let results = config.budgets.map(fn(budget) {
    evaluate_budget(snapshot, budget)
  })
  results.sort_by(fn(a, b) {
    let a_rank = match a.status {
      OverBudget => 0
      NearLimit => 1
      BudgetPathMissing => 2
      WithinBudget => 3
    }
    let b_rank = match b.status {
      OverBudget => 0
      NearLimit => 1
      BudgetPathMissing => 2
      WithinBudget => 3
    }
    if a_rank < b_rank {
      -1
    } else if a_rank > b_rank {
      1
    } else if a.usage_tenths > b.usage_tenths {
      -1
    } else if a.usage_tenths < b.usage_tenths {
      1
    } else if a.id < b.id {
      -1
    } else {
      1
    }
  })
  results
}

///|
pub fn budget_status_name(status : BudgetStatus) -> String {
  match status {
    WithinBudget => "within"
    NearLimit => "warning"
    OverBudget => "exceeded"
    BudgetPathMissing => "missing"
  }
}

///|
pub fn budget_exit_code(results : Array[BudgetResult]) -> Int {
  if results.any(fn(result) { result.status == OverBudget }) {
    2
  } else if results.any(fn(result) { result.status == NearLimit }) {
    1
  } else {
    0
  }
}

///|
pub fn budget_failure_threshold_from_name(
  name : String,
) -> BudgetFailureThreshold raise {
  match name.to_lower() {
    "never" => NeverFail
    "warning" => FailOnWarning
    "exceeded" => FailOnExceeded
    value =>
      raise Failure(
        "Unknown budget failure threshold: " +
        value +
        ". Expected never, warning, or exceeded.",
      )
  }
}

///|
pub fn budgets_should_fail(
  results : Array[BudgetResult],
  threshold : BudgetFailureThreshold,
) -> Bool {
  match threshold {
    NeverFail => false
    FailOnWarning =>
      results.any(fn(result) {
        result.status == NearLimit || result.status == OverBudget
      })
    FailOnExceeded => results.any(fn(result) { result.status == OverBudget })
  }
}

///|
pub fn render_budgets(results : Array[BudgetResult]) -> String {
  let lines : Array[String] = ["MoonDisk storage budgets"]
  for result in results {
    let path = if result.path == "" { "." } else { result.path }
    lines.push(
      "[" +
      budget_status_name(result.status) +
      "] " +
      result.id +
      " (" +
      path +
      ")",
    )
    if result.status == BudgetPathMissing {
      lines.push("  The configured path was not present in the snapshot.")
    } else {
      lines.push(
        "  " +
        format_bytes(result.actual_bytes) +
        " used of " +
        format_bytes(result.maximum_bytes) +
        " (" +
        format_percent(result.actual_bytes, result.maximum_bytes) +
        ").",
      )
    }
  }
  lines.join("\n")
}

///|
pub fn budget_config_to_json(config : BudgetConfig) -> String {
  ToJson::to_json(config).stringify(indent=2)
}

///|
pub fn budget_config_from_json(source : String) -> BudgetConfig raise {
  let config : BudgetConfig = @json.from_json(@json.parse(source))
  if config.format_version != 1 {
    raise Failure("Unsupported storage budget version")
  }
  let issues = validate_budget_config(config)
  if !issues.is_empty() {
    raise Failure("Invalid storage budget: " + issues[0].message)
  }
  config
}

///|
pub async fn write_budget_config(path : String, config : BudgetConfig) -> Unit {
  @fs.write_file(path, budget_config_to_json(config))
}

///|
pub async fn read_budget_config(path : String) -> BudgetConfig {
  budget_config_from_json(@fs.read_file(path).text())
}