///|
pub(all) enum PlanAction {
  Quarantine
  Skip
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) enum PlanIssueKind {
  DuplicateSource
  CoveredByParent
  DestinationCollision
  SourceMissing
  SourceChanged
  UnsafePath
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) struct PlanItem {
  source : String
  destination : String
  expected_size : Int64
  expected_modified_at : Int64
  action : PlanAction
  rule_id : String
  reason : String
  risk : RiskLevel
  regenerable : Bool
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) struct PlanIssue {
  kind : PlanIssueKind
  path : String
  message : String
} derive(Eq, Debug, ToJson, FromJson)

///|
pub(all) struct CleanupPlan {
  format_version : Int
  root : String
  quarantine_root : String
  created_at : Int64
  items : Array[PlanItem]
  issues : Array[PlanIssue]
  reclaimable_size : Int64
} derive(Eq, Debug, ToJson, FromJson)

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

///|
fn quarantine_destination(path : String) -> String {
  normalize_relative_path(path)
}

///|
fn selected_parent(path : String, included : Map[String, Bool]) -> String? {
  let mut parent = parent_path(path)
  while parent != "" {
    if included.get(parent) is Some(_) {
      return Some(parent)
    }
    parent = parent_path(parent)
  }
  None
}

///|
fn observed_entry_size(entry : Entry, snapshot : Snapshot) -> Int64 {
  if entry.is_file() {
    entry.size
  } else if entry.is_dir() {
    match stat_for_directory(snapshot, entry.path) {
      Some(stat) => stat.recursive_size
      None => 0L
    }
  } else {
    0L
  }
}

///|
pub fn build_cleanup_plan(
  snapshot : Snapshot,
  matches : Array[RuleMatch],
  quarantine_root : String,
  created_at : Int64,
) -> CleanupPlan {
  let items : Array[PlanItem] = []
  let issues : Array[PlanIssue] = []
  let included : Map[String, Bool] = Map([])
  let destinations : Map[String, String] = Map([])
  let mut reclaimable = 0L
  let ordered = matches.copy()
  ordered.sort_by(fn(a, b) {
    let a_depth = path_depth(a.path)
    let b_depth = path_depth(b.path)
    if a_depth < b_depth {
      -1
    } else if a_depth > b_depth {
      1
    } else if a.path < b.path {
      -1
    } else if a.path > b.path {
      1
    } else {
      0
    }
  })
  for candidate in ordered {
    if included.get(candidate.path) is Some(_) {
      issues.push(PlanIssue::{
        kind: DuplicateSource,
        path: candidate.path,
        message: "More than one cleanup rule selected this path; the first match wins.",
      })
      continue
    }
    match selected_parent(candidate.path, included) {
      Some(parent) => {
        issues.push(PlanIssue::{
          kind: CoveredByParent,
          path: candidate.path,
          message: "The selected parent " +
          parent +
          " already covers this path.",
        })
        continue
      }
      None => ()
    }
    included[candidate.path] = true
    if !safe_plan_path(candidate.path) {
      issues.push(PlanIssue::{
        kind: UnsafePath,
        path: candidate.path,
        message: "Cleanup plans only accept non-empty paths relative to the scan root.",
      })
      continue
    }
    match snapshot.find(candidate.path) {
      None =>
        issues.push(PlanIssue::{
          kind: SourceMissing,
          path: candidate.path,
          message: "The candidate is not present in the source snapshot.",
        })
      Some(entry) => {
        let destination = quarantine_destination(candidate.path)
        match destinations.get(destination) {
          Some(existing) =>
            issues.push(PlanIssue::{
              kind: DestinationCollision,
              path: candidate.path,
              message: "Destination is already used by " + existing + ".",
            })
          None => {
            destinations[destination] = candidate.path
            items.push(PlanItem::{
              source: candidate.path,
              destination,
              expected_size: candidate.size,
              expected_modified_at: entry.modified_at,
              action: Quarantine,
              rule_id: candidate.rule_id,
              reason: candidate.reason,
              risk: candidate.risk,
              regenerable: candidate.regenerable,
            })
            reclaimable += candidate.size
          }
        }
      }
    }
  }
  CleanupPlan::{
    format_version: 1,
    root: snapshot.root,
    quarantine_root,
    created_at,
    items,
    issues,
    reclaimable_size: reclaimable,
  }
}

///|
pub fn validate_plan_against_snapshot(
  plan : CleanupPlan,
  current : Snapshot,
) -> Array[PlanIssue] {
  let issues : Array[PlanIssue] = []
  for item in plan.items {
    match current.find(item.source) {
      None =>
        issues.push(PlanIssue::{
          kind: SourceMissing,
          path: item.source,
          message: "The source no longer exists.",
        })
      Some(entry) =>
        if observed_entry_size(entry, current) != item.expected_size ||
          entry.modified_at != item.expected_modified_at {
          issues.push(PlanIssue::{
            kind: SourceChanged,
            path: item.source,
            message: "The source changed after the cleanup plan was created.",
          })
        }
    }
  }
  issues
}

///|
pub fn CleanupPlan::low_risk_size(self : CleanupPlan) -> Int64 {
  let mut total = 0L
  for item in self.items {
    if item.risk == Low {
      total += item.expected_size
    }
  }
  total
}

///|
pub fn CleanupPlan::high_risk_count(self : CleanupPlan) -> Int {
  self.items.filter(fn(item) { item.risk == High }).length()
}

///|
pub fn plan_to_json(plan : CleanupPlan, pretty? : Bool = true) -> String {
  let json = ToJson::to_json(plan)
  if pretty {
    json.stringify(indent=2)
  } else {
    json.stringify()
  }
}

///|
pub fn plan_from_json(source : String) -> CleanupPlan raise {
  let plan : CleanupPlan = @json.from_json(@json.parse(source))
  if plan.format_version != 1 {
    raise Failure("Unsupported cleanup plan version")
  }
  plan
}

///|
pub async fn write_plan(path : String, plan : CleanupPlan) -> Unit {
  @fs.write_file(path, plan_to_json(plan))
}

///|
pub async fn read_plan(path : String) -> CleanupPlan {
  plan_from_json(@fs.read_file(path).text())
}