///|
fn sort_name(sort : SortOrder) -> String {
  match sort {
    InputOrder => "input"
    AreaDesc => "area-desc"
    HeightDesc => "height-desc"
    WidthDesc => "width-desc"
    PerimeterDesc => "perimeter-desc"
  }
}

///|
fn bool_name(value : Bool) -> String {
  if value {
    "true"
  } else {
    "false"
  }
}

///|
fn options_key(options : PackOptions) -> String {
  "\{algorithm_name(options.algorithm)}|\{sort_name(options.sort)}|rotate=\{bool_name(options.allow_rotate)}|outer=\{options.padding.outer}|inner=\{options.padding.inner}"
}

///|
fn same_options(left : PackOptions, right : PackOptions) -> Bool {
  left.bin == right.bin &&
  left.padding == right.padding &&
  left.sort == right.sort &&
  left.algorithm == right.algorithm &&
  left.allow_rotate == right.allow_rotate
}

///|
fn contains_options(
  options_list : ArrayView[PackOptions],
  options : PackOptions,
) -> Bool {
  for existing in options_list {
    if same_options(existing, options) {
      return true
    }
  }
  false
}

///|
fn push_options_once(
  options_list : Array[PackOptions],
  options : PackOptions,
) -> Unit {
  if !contains_options(options_list, options) {
    options_list.push(options)
  }
}

///|
fn candidate_options_from(
  base : PackOptions,
  algorithm : Algorithm,
  sort : SortOrder,
  rotate : Bool,
  inner : Int,
) -> PackOptions {
  PackOptions::{
    bin: base.bin,
    padding: Padding::{ outer: base.padding.outer, inner },
    sort,
    algorithm,
    allow_rotate: rotate,
  }
}

///|
fn available_algorithms() -> Array[Algorithm] {
  [ShelfFirstFit, GuillotineBestArea, SkylineBottomLeft]
}

///|
fn available_sorts() -> Array[SortOrder] {
  [InputOrder, AreaDesc, HeightDesc, WidthDesc, PerimeterDesc]
}

///|
fn available_rotations(base : PackOptions) -> Array[Bool] {
  if base.allow_rotate {
    [true, false]
  } else {
    [false, true]
  }
}

///|
fn push_inner_padding_variants(values : Array[Int], base_inner : Int) -> Unit {
  if base_inner >= 0 {
    values.push(base_inner)
  }
  if base_inner != 0 {
    values.push(0)
  }
  if base_inner != 1 {
    values.push(1)
  }
  if base_inner != 2 {
    values.push(2)
  }
}

///|
fn available_inner_paddings(base : PackOptions) -> Array[Int] {
  let values : Array[Int] = []
  push_inner_padding_variants(values, base.padding.inner)
  values
}

///|
fn build_option_space(base : PackOptions) -> Array[PackOptions] {
  let options_list : Array[PackOptions] = []
  for algorithm in available_algorithms() {
    for sort in available_sorts() {
      for rotate in available_rotations(base) {
        for inner in available_inner_paddings(base) {
          push_options_once(
            options_list,
            candidate_options_from(base, algorithm, sort, rotate, inner),
          )
        }
      }
    }
  }
  push_options_once(options_list, base)
  options_list
}

///|
fn pack_candidate_score(layout : Layout) -> Int {
  match analyze_layout(layout) {
    Err(_) => -100000000
    Ok(stats) => {
      let all_packed_bonus = if stats.unpacked_count == 0 { 20000 } else { 0 }
      let rotation_penalty = stats.rotated_count * 150
      layout_quality_score(stats) + all_packed_bonus - rotation_penalty
    }
  }
}

///|
fn make_candidate(options : PackOptions, layout : Layout) -> PackCandidate {
  PackCandidate::{
    options,
    layout,
    score: pack_candidate_score(layout),
    rank: 0,
  }
}

///|
fn compare_candidates_for_sort(
  left : PackCandidate,
  right : PackCandidate,
) -> Int {
  if left.score > right.score {
    -1
  } else if left.score < right.score {
    1
  } else {
    let lk = options_key(left.options)
    let rk = options_key(right.options)
    if lk < rk {
      -1
    } else if lk > rk {
      1
    } else {
      0
    }
  }
}

///|
fn rank_candidates(
  candidates : ArrayView[PackCandidate],
) -> Array[PackCandidate] {
  let sorted = candidates.to_owned()
  sorted.sort_by(compare_candidates_for_sort)
  let ranked : Array[PackCandidate] = []
  for i in 0.. Result[Array[PackCandidate], PackError] {
  let candidates : Array[PackCandidate] = []
  for options in build_option_space(base) {
    match pack(rects, options) {
      Err(err) => return Err(err)
      Ok(layout) => candidates.push(make_candidate(options, layout))
    }
  }
  Ok(candidates)
}

///|
/// Compare deterministic option combinations for the same input.
pub fn compare_packers(
  rects : ArrayView[Rect],
  base : PackOptions,
) -> Result[PackComparison, PackError] {
  match collect_candidates(rects, base) {
    Err(err) => Err(err)
    Ok(candidates) => {
      let ranked = rank_candidates(candidates)
      Ok(PackComparison::{
        base_options: base,
        candidates: ranked,
        best: ranked[0],
      })
    }
  }
}

///|
/// Pack with the highest-scoring option combination.
pub fn recommend_pack(
  rects : ArrayView[Rect],
  base : PackOptions,
) -> Result[Layout, PackError] {
  match compare_packers(rects, base) {
    Err(err) => Err(err)
    Ok(comparison) => Ok(comparison.best.layout)
  }
}

///|
/// Return the top candidate without discarding ranking metadata.
pub fn recommend_candidate(
  rects : ArrayView[Rect],
  base : PackOptions,
) -> Result[PackCandidate, PackError] {
  match compare_packers(rects, base) {
    Err(err) => Err(err)
    Ok(comparison) => Ok(comparison.best)
  }
}

///|
fn write_candidate_line(out : StringBuilder, candidate : PackCandidate) -> Unit {
  out.write_string("#")
  out.write_string("\{candidate.rank}")
  out.write_string(" ")
  out.write_string(options_key(candidate.options))
  out.write_string(" score=")
  out.write_string("\{candidate.score}")
  out.write_string(" placed=")
  out.write_string("\{candidate.layout.placements.length()}")
  out.write_string(" unpacked=")
  out.write_string("\{candidate.layout.unpacked.length()}")
  out.write_string(" occupancy=")
  out.write_string("\{candidate.layout.occupancy_permille}/1000")
  out.write_char('\n')
}

///|
/// Human-readable report for automatic option exploration.
pub fn to_comparison_report(comparison : PackComparison) -> String {
  let out = StringBuilder()
  out.write_string("rectloom option comparison\n")
  out.write_string("base=")
  out.write_string(options_key(comparison.base_options))
  out.write_char('\n')
  out.write_string("candidates=")
  out.write_string("\{comparison.candidates.length()}")
  out.write_char('\n')
  out.write_string("best=")
  out.write_string(options_key(comparison.best.options))
  out.write_char('\n')
  for candidate in comparison.candidates {
    write_candidate_line(out, candidate)
  }
  out.to_string()
}

///|
/// CSV output for spreadsheet-based result comparison.
pub fn to_comparison_csv(comparison : PackComparison) -> String {
  let out = StringBuilder()
  out.write_string(
    "rank,algorithm,sort,rotate,outer_padding,inner_padding,score,placed,unpacked,occupancy_permille,waste_area\n",
  )
  for candidate in comparison.candidates {
    out.write_string("\{candidate.rank},")
    out.write_string(algorithm_name(candidate.options.algorithm))
    out.write_char(',')
    out.write_string(sort_name(candidate.options.sort))
    out.write_char(',')
    out.write_string(bool_name(candidate.options.allow_rotate))
    out.write_char(',')
    out.write_string("\{candidate.options.padding.outer},")
    out.write_string("\{candidate.options.padding.inner},")
    out.write_string("\{candidate.score},")
    out.write_string("\{candidate.layout.placements.length()},")
    out.write_string("\{candidate.layout.unpacked.length()},")
    out.write_string("\{candidate.layout.occupancy_permille},")
    out.write_string("\{candidate.layout.waste_area}")
    out.write_char('\n')
  }
  out.to_string()
}