///|
/// Semantic roles used by reusable design themes.
pub(all) enum ThemeRole {
  Background
  Surface
  Primary
  Secondary
  Accent
  Text
  Muted
} derive(Eq, Debug, ToJson)

///|
/// A named color theme with semantic roles and a source palette.
pub(all) struct ColorTheme {
  name : String
  background : Color
  surface : Color
  primary : Color
  secondary : Color
  accent : Color
  text : Color
  muted : Color
  palette : Palette
} derive(Eq, Debug, ToJson)

///|
/// Builds a theme from a palette, using robust fallbacks for short palettes.
pub fn ColorTheme::new(name : String, palette : Palette) -> ColorTheme {
  let fallback = Color::rgb8(128, 128, 128)
  let at = fn(index) { palette.at(index).unwrap_or(fallback) }
  {
    name,
    background: at(0),
    surface: at(1),
    primary: at(2),
    secondary: at(3),
    accent: at(4),
    text: best_text_color(at(0)),
    muted: at(1).mix(at(0), 0.5),
    palette,
  }
}

///|
pub fn ColorTheme::with_role(
  self : ColorTheme,
  role : ThemeRole,
  color : Color,
) -> ColorTheme {
  match role {
    Background => { ..self, background: color }
    Surface => { ..self, surface: color }
    Primary => { ..self, primary: color }
    Secondary => { ..self, secondary: color }
    Accent => { ..self, accent: color }
    Text => { ..self, text: color }
    Muted => { ..self, muted: color }
  }
}

///|
pub fn ColorTheme::color(self : ColorTheme, role : ThemeRole) -> Color {
  match role {
    Background => self.background
    Surface => self.surface
    Primary => self.primary
    Secondary => self.secondary
    Accent => self.accent
    Text => self.text
    Muted => self.muted
  }
}

///|
pub fn ColorTheme::to_css(
  self : ColorTheme,
  prefix? : String = "--theme",
) -> String {
  let builder = StringBuilder()
  builder.write_string(":root {\n")
  builder.write_string("  \{prefix}-background: \{self.background.to_hex()};\n")
  builder.write_string("  \{prefix}-surface: \{self.surface.to_hex()};\n")
  builder.write_string("  \{prefix}-primary: \{self.primary.to_hex()};\n")
  builder.write_string("  \{prefix}-secondary: \{self.secondary.to_hex()};\n")
  builder.write_string("  \{prefix}-accent: \{self.accent.to_hex()};\n")
  builder.write_string("  \{prefix}-text: \{self.text.to_hex()};\n")
  builder.write_string("  \{prefix}-muted: \{self.muted.to_hex()};\n")
  builder.write_string("}\n")
  builder.to_string()
}

///|
/// Interpolation spaces supported by a color ramp.
pub(all) enum ColorRampSpace {
  Rgb
  Hsl
  Luminance
} derive(Eq, Debug, ToJson)

///|
/// A positioned color ramp for fields, charts and procedural surfaces.
pub(all) struct ColorRamp {
  stops : Array[Color]
  positions : Array[Double]
  space : ColorRampSpace
} derive(Eq, Debug, ToJson)

///|
pub fn ColorRamp::new(
  stops : Array[Color],
  space? : ColorRampSpace = Rgb,
) -> ColorRamp {
  let positions = []
  if stops.length() == 1 {
    positions.push(0.0)
  } else if stops.length() > 1 {
    for index in 0.. Color {
  if self.stops.length() == 0 {
    Color::rgb8(0, 0, 0)
  } else if self.stops.length() == 1 {
    self.stops[0]
  } else {
    let target = value.max(0.0).min(1.0)
    let mut index = 0
    for candidate in 0..= self.stops.length() - 1 {
      self.stops[self.stops.length() - 1]
    } else {
      let start = self.positions[index]
      let end = self.positions[index + 1]
      let amount = if end == start {
        0.0
      } else {
        (target - start) / (end - start)
      }
      interpolate_color(
        self.stops[index],
        self.stops[index + 1],
        amount,
        self.space,
      )
    }
  }
}

///|
pub fn ColorRamp::sample(self : ColorRamp, count : Int) -> Array[Color] {
  let result = []
  if count > 0 {
    for index in 0.. ColorRamp {
  let colors = []
  for offset in 1..<=self.stops.length() {
    colors.push(self.stops[self.stops.length() - offset])
  }
  ColorRamp::new(colors, space=self.space)
}

///|
fn hue_wrap(value : Double) -> Double {
  let cycle = 2.0 * @math.PI
  let wrapped = value % cycle
  if wrapped < 0.0 {
    wrapped + cycle
  } else {
    wrapped
  }
}

///|
fn interpolate_hue(left : Double, right : Double, amount : Double) -> Double {
  let delta = (right - left + @math.PI) % (2.0 * @math.PI) - @math.PI
  hue_wrap(left + delta * amount)
}

///|
fn interpolate_color(
  left : Color,
  right : Color,
  amount : Double,
  space : ColorRampSpace,
) -> Color {
  let t = amount.max(0.0).min(1.0)
  match space {
    Rgb => left.mix(right, t)
    Hsl => {
      let (lh, ls, ll) = color_to_hsl(left)
      let (rh, rs, rl) = color_to_hsl(right)
      Color::hsl(
        interpolate_hue(lh, rh, t),
        ls + (rs - ls) * t,
        ll + (rl - ll) * t,
        alpha=left.a + (right.a - left.a) * t,
      )
    }
    Luminance => {
      let luminance = left.luminance() +
        (right.luminance() - left.luminance()) * t
      let mixed = left.mix(right, t)
      recolor_luminance(mixed, luminance)
    }
  }
}

///|
/// Converts an sRGB color to hue, saturation and lightness.
pub fn color_to_hsl(color : Color) -> (Double, Double, Double) {
  let maximum = color.r.max(color.g).max(color.b)
  let minimum = color.r.min(color.g).min(color.b)
  let lightness = (maximum + minimum) / 2.0
  if maximum == minimum {
    (0.0, 0.0, lightness)
  } else {
    let delta = maximum - minimum
    let saturation = if lightness > 0.5 {
      delta / (2.0 - maximum - minimum)
    } else {
      delta / (maximum + minimum)
    }
    let hue = if maximum == color.r {
      (color.g - color.b) / delta + (if color.g < color.b { 6.0 } else { 0.0 })
    } else if maximum == color.g {
      (color.b - color.r) / delta + 2.0
    } else {
      (color.r - color.g) / delta + 4.0
    }
    (hue * @math.PI / 3.0, saturation, lightness)
  }
}

///|
/// Returns a color with approximately the requested relative luminance.
pub fn recolor_luminance(color : Color, target : Double) -> Color {
  let current = color.luminance()
  if current <= 0.000001 {
    Color::rgba(
      target.max(0.0).min(1.0),
      target.max(0.0).min(1.0),
      target.max(0.0).min(1.0),
      a=color.a,
    )
  } else {
    let scale = target.max(0.0).min(1.0) / current
    Color::rgba(color.r * scale, color.g * scale, color.b * scale, a=color.a)
  }
}

///|
/// Selects black or white text with the stronger contrast ratio.
pub fn best_text_color(background : Color) -> Color {
  let light = Color::rgb8(255, 255, 255)
  let dark = Color::rgb8(0, 0, 0)
  if background.contrast_ratio(light) >= background.contrast_ratio(dark) {
    light
  } else {
    dark
  }
}

///|
/// Returns whether a color is visually light under the sRGB luminance model.
pub fn is_light_color(color : Color, threshold? : Double = 0.5) -> Bool {
  color.luminance() >= threshold
}

///|
/// Creates a palette ordered from dark to light by luminance.
pub fn sort_palette_by_luminance(palette : Palette) -> Palette {
  let colors = palette.colors.copy()
  for i in 0.. Palette {
  let colors = if palette.colors.length() == 0 {
    []
  } else if palette.colors.length() == 1 || samples_per_gap <= 1 {
    palette.colors.copy()
  } else {
    let result = []
    for index in 0..<(palette.colors.length() - 1) {
      let left = palette.colors[index]
      let right = palette.colors[index + 1]
      for offset in 0.. Palette {
  let colors = []
  if count > 0 {
    for index in 0.. Color {
  color.with_alpha(color.a * (1.0 - amount.max(0.0).min(1.0)))
}

///|
/// Blends a foreground over a background with an explicit opacity.
pub fn blend_opacity(
  background : Color,
  foreground : Color,
  opacity : Double,
) -> Color {
  background.over(
    foreground.with_alpha(foreground.a * opacity.max(0.0).min(1.0)),
  )
}

///|
/// Returns the closest palette entry and its squared RGB distance.
pub fn nearest_palette_color(
  palette : Palette,
  target : Color,
) -> (Int, Double)? {
  if palette.colors.length() == 0 {
    None
  } else {
    let mut best_index = 0
    let mut best_distance = color_distance_squared(palette.colors[0], target)
    for index in 1.. Double {
  let dr = left.r - right.r
  let dg = left.g - right.g
  let db = left.b - right.b
  dr * dr + dg * dg + db * db
}

///|
/// Quantizes a color to the nearest entry of a palette.
pub fn quantize_color(color : Color, palette : Palette) -> Color {
  match nearest_palette_color(palette, color) {
    Some((index, _)) => palette.colors[index]
    None => color
  }
}

///|
/// A contrast audit result for a foreground/background pair collection.
pub(all) struct ColorAudit {
  minimum_ratio : Double
  maximum_ratio : Double
  pair_count : Int
  passing_pairs : Int
  passes : Bool
} derive(Eq, Debug, ToJson)

///|
/// Audits all theme text roles against their corresponding surfaces.
pub fn audit_theme(theme : ColorTheme, minimum_ratio : Double) -> ColorAudit {
  let pairs = [
    (theme.background, theme.text),
    (theme.surface, theme.text),
    (theme.primary, best_text_color(theme.primary)),
    (theme.secondary, best_text_color(theme.secondary)),
    (theme.accent, best_text_color(theme.accent)),
  ]
  let mut minimum = 1000.0
  let mut maximum = 0.0
  let mut passing = 0
  for pair in pairs {
    let background = pair.0
    let foreground = pair.1
    let ratio = background.contrast_ratio(foreground)
    minimum = minimum.min(ratio)
    maximum = maximum.max(ratio)
    if ratio >= minimum_ratio {
      passing += 1
    }
  }
  {
    minimum_ratio: minimum,
    maximum_ratio: maximum,
    pair_count: pairs.length(),
    passing_pairs: passing,
    passes: passing == pairs.length(),
  }
}