///|
/// Perceptual color coordinates used by palette auditing and export tooling.
pub(all) struct ChromaticColor {
  l : Double
  a : Double
  b : Double
  alpha : Double
} derive(Eq, Debug, ToJson)

///|
pub(all) enum ColorVisionMode {
  NormalVision
  Protanopia
  Deuteranopia
  Tritanopia
  Achromatopsia
} derive(Eq, Debug, ToJson)

///|
pub(all) struct ColorHistogram {
  bins : Int
  red : Array[Int]
  green : Array[Int]
  blue : Array[Int]
  luminance : Array[Int]
  total : Int
} derive(Eq, Debug, ToJson)

///|
pub(all) struct ContrastAuditEntry {
  foreground : Color
  background : Color
  ratio : Double
  normal_text : Bool
  large_text : Bool
  pass : Bool
} derive(Eq, Debug, ToJson)

///|
pub(all) struct ColorAnalysisReport {
  color_count : Int
  unique_count : Int
  luminance_min : Double
  luminance_max : Double
  luminance_mean : Double
  contrast_min : Double
  contrast_max : Double
  contrast_mean : Double
  saturation_mean : Double
  neutral_count : Int
  transparent_count : Int
} derive(Eq, Debug, ToJson)

///|
fn ca_clamp(value : Double, low : Double, high : Double) -> Double {
  value.max(low).min(high)
}

///|
fn ca_linear(value : Double) -> Double {
  if value <= 0.04045 {
    value / 12.92
  } else {
    @math.pow((value + 0.055) / 1.055, 2.4)
  }
}

///|
fn ca_srgb(value : Double) -> Double {
  if value <= 0.0031308 {
    value * 12.92
  } else {
    1.055 * @math.pow(value.max(0.0), 1.0 / 2.4) - 0.055
  }
}

///|
fn ca_rgb_to_xyz(color : Color) -> (Double, Double, Double) {
  let r = ca_linear(color.r)
  let g = ca_linear(color.g)
  let b = ca_linear(color.b)
  (
    r * 0.4124564 + g * 0.3575761 + b * 0.1804375,
    r * 0.2126729 + g * 0.7151522 + b * 0.0721750,
    r * 0.0193339 + g * 0.1191920 + b * 0.9503041,
  )
}

///|
fn ca_lab_f(value : Double) -> Double {
  let delta = 6.0 / 29.0
  if value > delta * delta * delta {
    @math.pow(value, 1.0 / 3.0)
  } else {
    value / (3.0 * delta * delta) + 4.0 / 29.0
  }
}

///|
fn ca_lab_inv(value : Double) -> Double {
  let delta = 6.0 / 29.0
  if value > delta {
    value * value * value
  } else {
    3.0 * delta * delta * (value - 4.0 / 29.0)
  }
}

///|
pub fn chromatic_from_color(color : Color) -> ChromaticColor {
  let (x, y, z) = ca_rgb_to_xyz(color)
  let fx = ca_lab_f(x / 0.95047)
  let fy = ca_lab_f(y / 1.0)
  let fz = ca_lab_f(z / 1.08883)
  {
    l: 116.0 * fy - 16.0,
    a: 500.0 * (fx - fy),
    b: 200.0 * (fy - fz),
    alpha: color.a,
  }
}

///|
pub fn chromatic_to_color(value : ChromaticColor) -> Color {
  let fy = (value.l + 16.0) / 116.0
  let fx = value.a / 500.0 + fy
  let fz = fy - value.b / 200.0
  let x = ca_lab_inv(fx) * 0.95047
  let y = ca_lab_inv(fy)
  let z = ca_lab_inv(fz) * 1.08883
  let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314
  let g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560
  let b = x * 0.0556434 + y * -0.2040259 + z * 1.0572252
  Color::rgba(
    ca_clamp(ca_srgb(r), 0.0, 1.0),
    ca_clamp(ca_srgb(g), 0.0, 1.0),
    ca_clamp(ca_srgb(b), 0.0, 1.0),
    a=ca_clamp(value.alpha, 0.0, 1.0),
  )
}

///|
pub fn chromatic_distance(
  left : ChromaticColor,
  right : ChromaticColor,
) -> Double {
  let dl = left.l - right.l
  let da = left.a - right.a
  let db = left.b - right.b
  (dl * dl + da * da + db * db).sqrt()
}

///|
pub fn chromatic_mix(
  left : ChromaticColor,
  right : ChromaticColor,
  amount : Double,
) -> ChromaticColor {
  let t = ca_clamp(amount, 0.0, 1.0)
  {
    l: left.l + (right.l - left.l) * t,
    a: left.a + (right.a - left.a) * t,
    b: left.b + (right.b - left.b) * t,
    alpha: left.alpha + (right.alpha - left.alpha) * t,
  }
}

///|
pub fn color_temperature(kelvin : Double) -> Color {
  let temperature = ca_clamp(kelvin, 1000.0, 40000.0) / 100.0
  let red = if temperature <= 66.0 {
    255.0
  } else {
    329.698727446 * @math.pow(temperature - 60.0, -0.1332047592)
  }
  let green = if temperature <= 66.0 {
    70.0 + temperature * 1.1
  } else {
    190.0 + (temperature - 66.0) * 0.55
  }
  let blue = if temperature >= 66.0 {
    255.0
  } else if temperature <= 19.0 {
    0.0
  } else {
    (temperature - 19.0) * 5.4
  }
  Color::rgb8(
    ca_clamp(red, 0.0, 255.0).to_int(),
    ca_clamp(green, 0.0, 255.0).to_int(),
    ca_clamp(blue, 0.0, 255.0).to_int(),
  )
}

///|
pub fn color_temperature_ramp(
  start_kelvin : Double,
  end_kelvin : Double,
  samples : Int,
) -> Array[Color] {
  let result = []
  if samples <= 0 {
    result
  } else if samples == 1 {
    [color_temperature(start_kelvin)]
  } else {
    for index in 0.. (Double, Double, Double) {
  let max_value = color.r.max(color.g).max(color.b)
  let min_value = color.r.min(color.g).min(color.b)
  let delta = max_value - min_value
  let hue = if delta == 0.0 {
    0.0
  } else if max_value == color.r {
    2.0 * @math.PI * ((color.g - color.b) / delta % 6.0) / 6.0
  } else if max_value == color.g {
    2.0 * @math.PI * ((color.b - color.r) / delta + 2.0) / 6.0
  } else {
    2.0 * @math.PI * ((color.r - color.g) / delta + 4.0) / 6.0
  }
  (
    if hue < 0.0 {
      hue + 2.0 * @math.PI
    } else {
      hue
    },
    if max_value == 0.0 {
      0.0
    } else {
      delta / max_value
    },
    max_value,
  )
}

///|
pub fn color_to_hsl_analysis(color : Color) -> (Double, Double, Double) {
  let max_value = color.r.max(color.g).max(color.b)
  let min_value = color.r.min(color.g).min(color.b)
  let lightness = (max_value + min_value) / 2.0
  let delta = max_value - min_value
  let saturation = if delta == 0.0 {
    0.0
  } else {
    delta / (1.0 - (2.0 * lightness - 1.0).abs())
  }
  let hue = color_to_hsv_analysis(color).0
  (hue, ca_clamp(saturation, 0.0, 1.0), lightness)
}

///|
pub fn analysis_hue_distance(left : Color, right : Color) -> Double {
  let a = color_to_hsv_analysis(left).0
  let b = color_to_hsv_analysis(right).0
  let distance = (a - b).abs()
  distance.min(2.0 * @math.PI - distance)
}

///|
pub fn analysis_saturation(color : Color) -> Double {
  color_to_hsv_analysis(color).1
}

///|
pub fn analysis_value(color : Color) -> Double {
  color_to_hsv_analysis(color).2
}

///|
pub fn color_histogram_analysis(raster : Raster, bins : Int) -> ColorHistogram {
  let size = bins.max(0)
  let red = []
  let green = []
  let blue = []
  let luminance = []
  for _ in 0.. 0 {
    for color in raster.pixels {
      let ri = (color.r * size.to_double()).to_int().min(size - 1).max(0)
      let gi = (color.g * size.to_double()).to_int().min(size - 1).max(0)
      let bi = (color.b * size.to_double()).to_int().min(size - 1).max(0)
      let li = (color.luminance() * size.to_double())
        .to_int()
        .min(size - 1)
        .max(0)
      red[ri] += 1
      green[gi] += 1
      blue[bi] += 1
      luminance[li] += 1
    }
  }
  { bins: size, red, green, blue, luminance, total: raster.pixels.length() }
}

///|
pub fn histogram_peak(values : Array[Int]) -> Int {
  if values.length() == 0 {
    -1
  } else {
    let mut best = 0
    for index in 1.. values[best] {
        best = index
      }
    }
    best
  }
}

///|
pub fn histogram_quantile(values : Array[Int], quantile : Double) -> Int {
  if values.length() == 0 {
    -1
  } else {
    let total = values.fold(init=0, fn(sum, item) { sum + item })
    if total <= 0 {
      0
    } else {
      let target = (ca_clamp(quantile, 0.0, 1.0) * (total - 1).to_double()).to_int()
      let mut cumulative = 0
      let mut answer = values.length() - 1
      for index, item in values {
        cumulative += item
        if cumulative > target && answer == values.length() - 1 {
          answer = index
        }
      }
      answer
    }
  }
}

///|
pub fn contrast_audit(
  foreground : Color,
  background : Color,
) -> ContrastAuditEntry {
  let ratio = foreground.contrast_ratio(background)
  {
    foreground,
    background,
    ratio,
    normal_text: ratio >= 4.5,
    large_text: ratio >= 3.0,
    pass: ratio >= 4.5,
  }
}

///|
pub fn contrast_audit_palette(
  foreground : Palette,
  background : Palette,
) -> Array[ContrastAuditEntry] {
  let result = []
  for front in foreground.colors {
    for back in background.colors {
      result.push(contrast_audit(front, back))
    }
  }
  result
}

///|
pub fn strongest_contrast(
  background : Color,
  candidates : Array[Color],
) -> Color? {
  if candidates.length() == 0 {
    None
  } else {
    let mut best = candidates[0]
    let mut score = best.contrast_ratio(background)
    for candidate in candidates[1:] {
      let candidate_score = candidate.contrast_ratio(background)
      if candidate_score > score {
        best = candidate
        score = candidate_score
      }
    }
    Some(best)
  }
}

///|
pub fn readable_foreground(
  background : Color,
  light : Color,
  dark : Color,
) -> Color {
  if light.contrast_ratio(background) >= dark.contrast_ratio(background) {
    light
  } else {
    dark
  }
}

///|
pub fn simulate_color_vision(color : Color, mode : ColorVisionMode) -> Color {
  let r = color.r
  let g = color.g
  let b = color.b
  let channels = match mode {
    NormalVision => (r, g, b)
    Protanopia =>
      (
        0.56667 * r + 0.43333 * g,
        0.55833 * r + 0.44167 * g,
        0.24167 * g + 0.75833 * b,
      )
    Deuteranopia =>
      (0.625 * r + 0.375 * g, 0.7 * r + 0.3 * g, 0.3 * g + 0.7 * b)
    Tritanopia =>
      (0.95 * r + 0.05 * g, 0.43333 * g + 0.56667 * b, 0.475 * g + 0.525 * b)
    Achromatopsia => {
      let grey = color.luminance()
      (grey, grey, grey)
    }
  }
  Color::rgba(
    ca_clamp(channels.0, 0.0, 1.0),
    ca_clamp(channels.1, 0.0, 1.0),
    ca_clamp(channels.2, 0.0, 1.0),
    a=color.a,
  )
}

///|
fn ca_mode_name(mode : ColorVisionMode) -> String {
  match mode {
    NormalVision => "normal"
    Protanopia => "protanopia"
    Deuteranopia => "deuteranopia"
    Tritanopia => "tritanopia"
    Achromatopsia => "achromatopsia"
  }
}

///|
pub fn simulate_palette(palette : Palette, mode : ColorVisionMode) -> Palette {
  let colors = []
  for color in palette.colors {
    colors.push(simulate_color_vision(color, mode))
  }
  Palette::new(palette.name + "-" + ca_mode_name(mode), colors)
}

///|
pub fn palette_collision_count(palette : Palette, tolerance : Double) -> Int {
  let mut count = 0
  let threshold = tolerance.max(0.0)
  if palette.colors.length() > 1 {
    for i in 0..<(palette.colors.length() - 1) {
      for j in (i + 1).. Color? {
  if palette.colors.length() == 0 {
    None
  } else {
    let target = chromatic_from_color(color)
    let mut best = palette.colors[0]
    let mut distance = chromatic_distance(target, chromatic_from_color(best))
    for candidate in palette.colors[1:] {
      let candidate_distance = chromatic_distance(
        target,
        chromatic_from_color(candidate),
      )
      if candidate_distance < distance {
        best = candidate
        distance = candidate_distance
      }
    }
    Some(best)
  }
}

///|
pub fn quantize_palette(
  colors : Array[Color],
  target_count : Int,
  iterations : Int,
) -> Palette {
  let count = target_count.max(0).min(colors.length())
  let centers = []
  if count > 0 {
    for index in 0.. 0 {
          let weight = weights[index].to_double()
          let sum = sums[index]
          centers[index] = {
            l: sum.l / weight,
            a: sum.a / weight,
            b: sum.b / weight,
            alpha: sum.alpha / weight,
          }
        }
      }
    }
  }
  let result = []
  for center in centers {
    result.push(chromatic_to_color(center))
  }
  Palette::new("quantized", result)
}

///|
pub fn palette_sort_by_luminance(
  palette : Palette,
  descending : Bool,
) -> Palette {
  let colors = palette.colors.copy()
  for i in 1.. 0 {
      let previous_score = colors[j - 1].luminance()
      let should_move = if descending {
        previous_score < score
      } else {
        previous_score > score
      }
      if should_move {
        colors[j] = colors[j - 1]
        j -= 1
      } else {
        break
      }
    }
    colors[j] = value
  }
  Palette::new(palette.name + "-luminance", colors)
}

///|
pub fn palette_sort_by_chroma(palette : Palette, descending : Bool) -> Palette {
  let colors = palette.colors.copy()
  for i in 1.. 0 {
      let previous = chromatic_from_color(colors[j - 1])
      let previous_chroma = previous.a.abs() + previous.b.abs()
      let should_move = if descending {
        previous_chroma < value_chroma
      } else {
        previous_chroma > value_chroma
      }
      if should_move {
        colors[j] = colors[j - 1]
        j -= 1
      } else {
        break
      }
    }
    colors[j] = value
  }
  Palette::new(palette.name + "-chroma", colors)
}

///|
pub fn palette_expand_complementary(palette : Palette) -> Palette {
  let colors = []
  for color in palette.colors {
    colors.push(color)
    colors.push(
      Color::rgba(1.0 - color.r, 1.0 - color.g, 1.0 - color.b, a=color.a),
    )
  }
  Palette::new(palette.name + "-complementary", colors)
}

///|
pub fn palette_tints(palette : Palette, steps : Int) -> Palette {
  let colors = []
  for color in palette.colors {
    for index in 0.. Palette {
  let colors = []
  for color in palette.colors {
    for index in 0.. ColorAnalysisReport {
  let count = palette.colors.length()
  if count == 0 {
    {
      color_count: 0,
      unique_count: 0,
      luminance_min: 0.0,
      luminance_max: 0.0,
      luminance_mean: 0.0,
      contrast_min: 0.0,
      contrast_max: 0.0,
      contrast_mean: 0.0,
      saturation_mean: 0.0,
      neutral_count: 0,
      transparent_count: 0,
    }
  } else {
    let mut lum_min = palette.colors[0].luminance()
    let mut lum_max = lum_min
    let mut lum_sum = 0.0
    let mut sat_sum = 0.0
    let mut neutral = 0
    let mut transparent = 0
    for color in palette.colors {
      let lum = color.luminance()
      lum_min = lum_min.min(lum)
      lum_max = lum_max.max(lum)
      lum_sum += lum
      sat_sum += analysis_saturation(color)
      if analysis_saturation(color) < 0.08 {
        neutral += 1
      }
      if color.a < 0.999 {
        transparent += 1
      }
    }
    let mut contrast_min = palette.colors[0].contrast_ratio(palette.colors[0])
    let mut contrast_max = contrast_min
    let mut contrast_sum = 0.0
    let mut pairs = 0
    if count > 1 {
      for i in 0..<(count - 1) {
        for j in (i + 1).. String {
  "colors=\{report.color_count} unique=\{report.unique_count} luminance=\{report.luminance_min}..\{report.luminance_max} mean=\{report.luminance_mean} contrast=\{report.contrast_min}..\{report.contrast_max} mean=\{report.contrast_mean} saturation=\{report.saturation_mean} neutral=\{report.neutral_count} transparent=\{report.transparent_count}"
}

///|
pub fn color_analysis_css(palette : Palette, prefix : String) -> String {
  let builder = StringBuilder()
  builder.write_string(":root {\n")
  for index, color in palette.colors {
    builder.write_string("  --\{prefix}-\{index}: \{color.to_hex()};\n")
  }
  builder.write_string("}\n")
  builder.to_string()
}

///|
pub fn palette_interpolate_lab(
  left : Palette,
  right : Palette,
  amount : Double,
) -> Palette {
  let size = left.colors.length().min(right.colors.length())
  let colors = []
  for index in 0.. Palette {
  let colors = []
  for color in palette.colors {
    let value = color.luminance()
    colors.push(Color::rgba(value, value, value, a=color.a))
  }
  Palette::new(palette.name + "-gray", colors)
}

///|
pub fn palette_warm_cool(palette : Palette, warm_amount : Double) -> Palette {
  let colors = []
  let amount = ca_clamp(warm_amount, -1.0, 1.0)
  for color in palette.colors {
    let warm = if amount >= 0.0 {
      Color::rgba(
        color.r + amount * 0.12,
        color.g - amount * 0.04,
        color.b - amount * 0.1,
        a=color.a,
      )
    } else {
      Color::rgba(
        color.r + amount * 0.1,
        color.g - amount * 0.02,
        color.b - amount * 0.12,
        a=color.a,
      )
    }
    colors.push(warm)
  }
  Palette::new(palette.name + "-temperature", colors)
}

///|
pub fn palette_alpha_ramp(
  palette : Palette,
  start : Double,
  end : Double,
) -> Palette {
  let colors = []
  let count = palette.colors.length()
  for index, color in palette.colors {
    let amount = if count <= 1 {
      0.0
    } else {
      index.to_double() / (count - 1).to_double()
    }
    colors.push(color.with_alpha(start + (end - start) * amount))
  }
  Palette::new(palette.name + "-alpha", colors)
}

///|
pub fn palette_dedupe(palette : Palette, tolerance : Double) -> Palette {
  let result = []
  for color in palette.colors {
    let mut duplicate = false
    for existing in result {
      if chromatic_distance(
          chromatic_from_color(color),
          chromatic_from_color(existing),
        ) <=
        tolerance.max(0.0) {
        duplicate = true
        break
      }
    }
    if !duplicate {
      result.push(color)
    }
  }
  Palette::new(palette.name + "-dedupe", result)
}

///|
pub fn palette_ensure_contrast(
  palette : Palette,
  background : Color,
  minimum : Double,
) -> Palette {
  let colors = []
  for color in palette.colors {
    if color.contrast_ratio(background) >= minimum {
      colors.push(color)
    } else {
      let target = if background.luminance() > 0.5 {
        Color::rgb8(0, 0, 0)
      } else {
        Color::rgb8(255, 255, 255)
      }
      let mut adjusted = color
      for step in 1..<21 {
        let candidate = color.mix(target, step.to_double() / 20.0)
        if candidate.contrast_ratio(background) >= minimum {
          adjusted = candidate
          break
        }
      }
      colors.push(adjusted)
    }
  }
  Palette::new(palette.name + "-accessible", colors)
}

///|
pub fn palette_distance_matrix(palette : Palette) -> Array[Array[Double]] {
  let matrix = []
  for left in palette.colors {
    let row = []
    for right in palette.colors {
      row.push(
        chromatic_distance(
          chromatic_from_color(left),
          chromatic_from_color(right),
        ),
      )
    }
    matrix.push(row)
  }
  matrix
}

///|
pub fn color_sequence_score(colors : Array[Color]) -> Double {
  if colors.length() < 2 {
    0.0
  } else {
    let mut total = 0.0
    for index in 0..<(colors.length() - 1) {
      total += chromatic_distance(
        chromatic_from_color(colors[index]),
        chromatic_from_color(colors[index + 1]),
      )
    }
    total / (colors.length() - 1).to_double()
  }
}

///|
pub fn palette_reorder_maximum_separation(palette : Palette) -> Palette {
  if palette.colors.length() <= 2 {
    palette
  } else {
    let remaining = palette.colors.copy()
    let ordered = [remaining.remove(0)]
    while remaining.length() > 0 {
      let mut selected = 0
      let mut score = -1.0
      for index, candidate in remaining {
        let mut nearest = 1000000.0
        for existing in ordered {
          nearest = nearest.min(
            chromatic_distance(
              chromatic_from_color(candidate),
              chromatic_from_color(existing),
            ),
          )
        }
        if nearest > score {
          selected = index
          score = nearest
        }
      }
      ordered.push(remaining.remove(selected))
    }
    Palette::new(palette.name + "-separated", ordered)
  }
}

///|
pub fn palette_report_csv(palette : Palette) -> String {
  let builder = StringBuilder()
  builder.write_string("index,hex,luminance,hue,saturation,value\n")
  for index, color in palette.colors {
    let (hue, saturation, value) = color_to_hsv_analysis(color)
    builder.write_string(
      "\{index},\{color.to_hex()},\{color.luminance()},\{hue},\{saturation},\{value}\n",
    )
  }
  builder.to_string()
}

///|
pub fn color_blend_screen(left : Color, right : Color) -> Color {
  Color::rgba(
    1.0 - (1.0 - left.r) * (1.0 - right.r),
    1.0 - (1.0 - left.g) * (1.0 - right.g),
    1.0 - (1.0 - left.b) * (1.0 - right.b),
    a=left.a.max(right.a),
  )
}

///|
pub fn color_blend_multiply(left : Color, right : Color) -> Color {
  Color::rgba(
    left.r * right.r,
    left.g * right.g,
    left.b * right.b,
    a=left.a.max(right.a),
  )
}

///|
pub fn color_blend_overlay(left : Color, right : Color) -> Color {
  fn overlay(a : Double, b : Double) -> Double {
    if a < 0.5 {
      2.0 * a * b
    } else {
      1.0 - 2.0 * (1.0 - a) * (1.0 - b)
    }
  }
  Color::rgba(
    overlay(left.r, right.r),
    overlay(left.g, right.g),
    overlay(left.b, right.b),
    a=left.a.max(right.a),
  )
}

///|
pub fn color_blend_difference(left : Color, right : Color) -> Color {
  Color::rgba(
    (left.r - right.r).abs(),
    (left.g - right.g).abs(),
    (left.b - right.b).abs(),
    a=left.a.max(right.a),
  )
}

///|
pub fn color_blend_mode(left : Color, right : Color, mode : String) -> Color {
  if mode == "multiply" {
    color_blend_multiply(left, right)
  } else if mode == "screen" {
    color_blend_screen(left, right)
  } else if mode == "difference" {
    color_blend_difference(left, right)
  } else {
    color_blend_overlay(left, right)
  }
}

///|
pub fn raster_average_color(raster : Raster) -> Color {
  if raster.pixels.length() == 0 {
    Color::rgba(0.0, 0.0, 0.0, a=0.0)
  } else {
    let mut r = 0.0
    let mut g = 0.0
    let mut b = 0.0
    let mut a = 0.0
    for color in raster.pixels {
      r += color.r
      g += color.g
      b += color.b
      a += color.a
    }
    let count = raster.pixels.length().to_double()
    Color::rgba(r / count, g / count, b / count, a=a / count)
  }
}

///|
pub fn raster_dominant_colors(
  raster : Raster,
  count : Int,
  iterations : Int,
) -> Palette {
  let colors = raster.pixels.copy()
  quantize_palette(colors, count, iterations)
}

///|
pub fn raster_colorfulness(raster : Raster) -> Double {
  if raster.pixels.length() == 0 {
    0.0
  } else {
    raster.pixels.fold(init=0.0, fn(sum, color) {
      sum + analysis_saturation(color)
    }) /
    raster.pixels.length().to_double()
  }
}

///|
pub fn raster_contrast_span(raster : Raster) -> Double {
  if raster.pixels.length() == 0 {
    0.0
  } else {
    let mut low = raster.pixels[0].luminance()
    let mut high = low
    for color in raster.pixels {
      low = low.min(color.luminance())
      high = high.max(color.luminance())
    }
    high - low
  }
}

///|
pub fn audit_palette_for_modes(
  palette : Palette,
) -> Array[(ColorVisionMode, Int)] {
  let result = []
  for mode in [Protanopia, Deuteranopia, Tritanopia, Achromatopsia] {
    result.push(
      (mode, palette_collision_count(simulate_palette(palette, mode), 4.0)),
    )
  }
  result
}

///|
pub fn palette_quality_score(palette : Palette, background : Color) -> Double {
  let report = analyze_palette(palette)
  let contrast = if report.contrast_max <= 1.0 {
    0.0
  } else {
    (report.contrast_mean - 1.0) / (report.contrast_max - 1.0)
  }
  let diversity = (report.saturation_mean + report.contrast_max / 21.0).min(1.0)
  let readability = palette.colors.fold(init=0.0, fn(sum, color) {
      sum + color.contrast_ratio(background).min(7.0) / 7.0
    }) /
    palette.colors.length().max(1).to_double()
  ca_clamp(0.35 * contrast + 0.3 * diversity + 0.35 * readability, 0.0, 1.0)
}