///|
pub fn placement_change_name(kind : PlacementChangeKind) -> String {
  match kind {
    Added => "added"
    Removed => "removed"
    Moved => "moved"
    Resized => "resized"
    RotationChanged => "rotation-changed"
    Unchanged => "unchanged"
  }
}

///|
fn find_placement_by_id(placements : ArrayView[Placed], id : String) -> Placed? {
  for placed in placements {
    if placed.id == id {
      return Some(placed)
    }
  }
  None
}

///|
fn placement_area_delta(old : Placed, fresh : Placed) -> Int {
  placed_area(fresh) - placed_area(old)
}

///|
fn placement_moved(old : Placed, fresh : Placed) -> Bool {
  old.x != fresh.x || old.y != fresh.y
}

///|
fn placement_resized(old : Placed, fresh : Placed) -> Bool {
  old.width != fresh.width || old.height != fresh.height
}

///|
fn placement_rotation_changed(old : Placed, fresh : Placed) -> Bool {
  old.rotated != fresh.rotated
}

///|
fn compare_existing_placement(old : Placed, fresh : Placed) -> PlacementChange {
  let kind = if placement_resized(old, fresh) {
    Resized
  } else if placement_rotation_changed(old, fresh) {
    RotationChanged
  } else if placement_moved(old, fresh) {
    Moved
  } else {
    Unchanged
  }
  PlacementChange::{
    id: old.id,
    kind,
    old_x: old.x,
    old_y: old.y,
    old_width: old.width,
    old_height: old.height,
    new_x: fresh.x,
    new_y: fresh.y,
    new_width: fresh.width,
    new_height: fresh.height,
    area_delta: placement_area_delta(old, fresh),
  }
}

///|
fn removed_change(old : Placed) -> PlacementChange {
  PlacementChange::{
    id: old.id,
    kind: Removed,
    old_x: old.x,
    old_y: old.y,
    old_width: old.width,
    old_height: old.height,
    new_x: 0,
    new_y: 0,
    new_width: 0,
    new_height: 0,
    area_delta: 0 - placed_area(old),
  }
}

///|
fn added_change(fresh : Placed) -> PlacementChange {
  PlacementChange::{
    id: fresh.id,
    kind: Added,
    old_x: 0,
    old_y: 0,
    old_width: 0,
    old_height: 0,
    new_x: fresh.x,
    new_y: fresh.y,
    new_width: fresh.width,
    new_height: fresh.height,
    area_delta: placed_area(fresh),
  }
}

///|
fn collect_left_changes(
  changes : Array[PlacementChange],
  left : Layout,
  right : Layout,
) -> Unit {
  for old in left.placements {
    match find_placement_by_id(right.placements, old.id) {
      Some(fresh) => changes.push(compare_existing_placement(old, fresh))
      None => changes.push(removed_change(old))
    }
  }
}

///|
fn collect_right_additions(
  changes : Array[PlacementChange],
  left : Layout,
  right : Layout,
) -> Unit {
  for fresh in right.placements {
    match find_placement_by_id(left.placements, fresh.id) {
      Some(_) => ()
      None => changes.push(added_change(fresh))
    }
  }
}

///|
fn count_change_kind(
  changes : ArrayView[PlacementChange],
  kind : PlacementChangeKind,
) -> Int {
  let mut count = 0
  for change in changes {
    if change.kind == kind {
      count = count + 1
    }
  }
  count
}

///|
/// Compare two validated layouts by placement id.
pub fn diff_layouts(
  left : Layout,
  right : Layout,
) -> Result[LayoutDiff, PackError] {
  match validate_layout(left) {
    Err(err) => return Err(err)
    Ok(_) => ()
  }
  match validate_layout(right) {
    Err(err) => return Err(err)
    Ok(_) => ()
  }
  let changes : Array[PlacementChange] = []
  collect_left_changes(changes, left, right)
  collect_right_additions(changes, left, right)
  Ok(LayoutDiff::{
    left_algorithm: left.algorithm,
    right_algorithm: right.algorithm,
    left_occupancy_permille: left.occupancy_permille,
    right_occupancy_permille: right.occupancy_permille,
    occupancy_delta: right.occupancy_permille - left.occupancy_permille,
    added_count: count_change_kind(changes, Added),
    removed_count: count_change_kind(changes, Removed),
    moved_count: count_change_kind(changes, Moved),
    resized_count: count_change_kind(changes, Resized),
    rotation_changed_count: count_change_kind(changes, RotationChanged),
    unchanged_count: count_change_kind(changes, Unchanged),
    changes,
  })
}

///|
fn write_change(out : StringBuilder, change : PlacementChange) -> Unit {
  out.write_string("- ")
  out.write_string(change.id)
  out.write_string(" ")
  out.write_string(placement_change_name(change.kind))
  out.write_string(" old=")
  out.write_string(
    "\{change.old_x},\{change.old_y},\{change.old_width},\{change.old_height}",
  )
  out.write_string(" new=")
  out.write_string(
    "\{change.new_x},\{change.new_y},\{change.new_width},\{change.new_height}",
  )
  out.write_string(" area_delta=")
  out.write_string("\{change.area_delta}")
  out.write_char('\n')
}

///|
/// Human-readable layout diff.
pub fn to_layout_diff_report(diff : LayoutDiff) -> String {
  let out = StringBuilder()
  out.write_string("rectloom layout diff\n")
  out.write_string("left=")
  out.write_string(algorithm_name(diff.left_algorithm))
  out.write_string(" right=")
  out.write_string(algorithm_name(diff.right_algorithm))
  out.write_string(" occupancy_delta=")
  out.write_string("\{diff.occupancy_delta}")
  out.write_char('\n')
  out.write_string("added=")
  out.write_string("\{diff.added_count}")
  out.write_string(" removed=")
  out.write_string("\{diff.removed_count}")
  out.write_string(" moved=")
  out.write_string("\{diff.moved_count}")
  out.write_string(" resized=")
  out.write_string("\{diff.resized_count}")
  out.write_string(" rotation_changed=")
  out.write_string("\{diff.rotation_changed_count}")
  out.write_string(" unchanged=")
  out.write_string("\{diff.unchanged_count}")
  out.write_char('\n')
  for change in diff.changes {
    write_change(out, change)
  }
  out.to_string()
}

///|
/// CSV layout diff for snapshots and spreadsheets.
pub fn to_layout_diff_csv(diff : LayoutDiff) -> String {
  let out = StringBuilder()
  out.write_string(
    "id,kind,old_x,old_y,old_width,old_height,new_x,new_y,new_width,new_height,area_delta\n",
  )
  for change in diff.changes {
    out.write_string(csv_escape(change.id))
    out.write_char(',')
    out.write_string(placement_change_name(change.kind))
    out.write_char(',')
    out.write_string("\{change.old_x},")
    out.write_string("\{change.old_y},")
    out.write_string("\{change.old_width},")
    out.write_string("\{change.old_height},")
    out.write_string("\{change.new_x},")
    out.write_string("\{change.new_y},")
    out.write_string("\{change.new_width},")
    out.write_string("\{change.new_height},")
    out.write_string("\{change.area_delta}")
    out.write_char('\n')
  }
  out.to_string()
}

///|
/// Convenience wrapper to compare two algorithms using the same input.
pub fn diff_algorithms(
  rects : ArrayView[Rect],
  base : PackOptions,
  left_algorithm : Algorithm,
  right_algorithm : Algorithm,
) -> Result[LayoutDiff, PackError] {
  let left_options = PackOptions::{
    bin: base.bin,
    padding: base.padding,
    sort: base.sort,
    algorithm: left_algorithm,
    allow_rotate: base.allow_rotate,
  }
  let right_options = PackOptions::{
    bin: base.bin,
    padding: base.padding,
    sort: base.sort,
    algorithm: right_algorithm,
    allow_rotate: base.allow_rotate,
  }
  let left = match pack(rects, left_options) {
    Err(err) => return Err(err)
    Ok(layout) => layout
  }
  let right = match pack(rects, right_options) {
    Err(err) => return Err(err)
    Ok(layout) => layout
  }
  diff_layouts(left, right)
}