///|
pub(all) enum Temperature {
  TemperatureCool
  TemperatureNeutral
  TemperatureWarm
} derive(Eq, Debug)

///|
pub(all) enum Severity {
  SeverityInfo
  SeverityWarning
  SeverityError
} derive(Eq, Debug)

///|
pub(all) enum TokenDiagnosticKind {
  DiagnosticEmptyName
  DiagnosticMissingAssignment
  DiagnosticUnknownRole
  DiagnosticBadColor(ParseError)
} derive(Eq, Debug)

///|
pub(all) enum RepairStrategy {
  RepairKeep
  RepairUseIdealText
  RepairDarkenForeground
  RepairLightenForeground
  RepairDarkenBackground
  RepairLightenBackground
} derive(Eq, Debug)

///|
pub(all) enum HarmonyKind {
  HarmonyComplementary
  HarmonyAnalogous
  HarmonyTriadic
  HarmonySplitComplementary
  HarmonyMonochrome
  HarmonyCustom
} derive(Eq, Debug)

///|
pub(all) struct Hsl {
  h : Double
  s : Double
  l : Double
} derive(Eq, Debug)

///|
pub(all) struct ChannelStats {
  min : Int
  max : Int
  average : Double
} derive(Eq, Debug)

///|
pub(all) struct ColorProfile {
  color : Color
  hex : String
  luminance : Double
  brightness : Double
  saturation : Double
  hue : Double
  temperature : Temperature
  ideal_text : Color
} derive(Eq, Debug)

///|
pub(all) struct RepairCandidate {
  color : Color
  ratio : Double
  delta : Double
  strategy : RepairStrategy
  steps : Int
} derive(Eq, Debug)

///|
pub(all) struct TokenDiagnostic {
  line : Int
  column : Int
  token : String
  severity : Severity
  kind : TokenDiagnosticKind
  message : String
} derive(Eq, Debug)

///|
pub(all) struct TokenParseReport {
  swatches : Array[Swatch]
  diagnostics : Array[TokenDiagnostic]
  ignored : Int
} derive(Eq, Debug)

///|
pub(all) struct PaletteStats {
  swatches : Int
  foregrounds : Int
  backgrounds : Int
  borders : Int
  average_luminance : Double
  darkest : Swatch?
  lightest : Swatch?
} derive(Eq, Debug)

///|
pub(all) struct RampAudit {
  family : String
  swatches : Array[Swatch]
  monotonic_luminance : Bool
  duplicate_hexes : Array[String]
  smallest_luminance_gap : Double
} derive(Eq, Debug)

///|
pub(all) struct DocumentAudit {
  tokens : TokenParseReport
  palette : PaletteReport
  stats : PaletteStats
} derive(Eq, Debug)

///|
pub fn Temperature::label(self : Temperature) -> String {
  match self {
    TemperatureCool => "cool"
    TemperatureNeutral => "neutral"
    TemperatureWarm => "warm"
  }
}

///|
pub fn Severity::label(self : Severity) -> String {
  match self {
    SeverityInfo => "info"
    SeverityWarning => "warning"
    SeverityError => "error"
  }
}

///|
pub fn RepairStrategy::label(self : RepairStrategy) -> String {
  match self {
    RepairKeep => "keep"
    RepairUseIdealText => "use-ideal-text"
    RepairDarkenForeground => "darken-foreground"
    RepairLightenForeground => "lighten-foreground"
    RepairDarkenBackground => "darken-background"
    RepairLightenBackground => "lighten-background"
  }
}

///|
pub fn HarmonyKind::label(self : HarmonyKind) -> String {
  match self {
    HarmonyComplementary => "complementary"
    HarmonyAnalogous => "analogous"
    HarmonyTriadic => "triadic"
    HarmonySplitComplementary => "split-complementary"
    HarmonyMonochrome => "monochrome"
    HarmonyCustom => "custom"
  }
}

///|
pub fn Color::red(self : Color) -> Int {
  self.r
}

///|
pub fn Color::green(self : Color) -> Int {
  self.g
}

///|
pub fn Color::blue(self : Color) -> Int {
  self.b
}

///|
pub fn Color::profile(self : Color) -> ColorProfile {
  let hsl = self.to_hsl()
  let ideal = self.ideal_text_color()
  {
    color: self,
    hex: self.to_hex(),
    luminance: self.relative_luminance(),
    brightness: self.perceived_brightness(),
    saturation: hsl.s,
    hue: hsl.h,
    temperature: self.temperature(),
    ideal_text: ideal,
  }
}

///|
pub fn Color::perceived_brightness(self : Color) -> Double {
  (
    self.r.to_double() * 299.0 +
    self.g.to_double() * 587.0 +
    self.b.to_double() * 114.0
  ) /
  1000.0
}

///|
pub fn Color::is_dark(self : Color) -> Bool {
  self.relative_luminance() < 0.35
}

///|
pub fn Color::is_light(self : Color) -> Bool {
  self.relative_luminance() > 0.65
}

///|
pub fn Color::ideal_text_color(self : Color) -> Color {
  let black = unchecked_color(0, 0, 0)
  let white = unchecked_color(255, 255, 255)
  if contrast_ratio(black, self) >= contrast_ratio(white, self) {
    black
  } else {
    white
  }
}

///|
pub fn Color::temperature(self : Color) -> Temperature {
  let h = self.to_hsl().h
  if self.to_hsl().s < 0.08 {
    TemperatureNeutral
  } else if h >= 35.0 && h <= 165.0 {
    TemperatureWarm
  } else if h >= 200.0 && h <= 310.0 {
    TemperatureCool
  } else if self.r > self.b {
    TemperatureWarm
  } else if self.b > self.r {
    TemperatureCool
  } else {
    TemperatureNeutral
  }
}

///|
pub fn Color::mix(self : Color, other : Color, amount : Double) -> Color {
  let t = clamp_double(amount, 0.0, 1.0)
  unchecked_color(
    round_channel(self.r.to_double() * (1.0 - t) + other.r.to_double() * t),
    round_channel(self.g.to_double() * (1.0 - t) + other.g.to_double() * t),
    round_channel(self.b.to_double() * (1.0 - t) + other.b.to_double() * t),
  )
}

///|
pub fn Color::lighten(self : Color, amount : Double) -> Color {
  self.mix(unchecked_color(255, 255, 255), amount)
}

///|
pub fn Color::darken(self : Color, amount : Double) -> Color {
  self.mix(unchecked_color(0, 0, 0), amount)
}

///|
pub fn Color::invert(self : Color) -> Color {
  unchecked_color(255 - self.r, 255 - self.g, 255 - self.b)
}

///|
pub fn Color::grayscale(self : Color) -> Color {
  let y = round_channel(self.perceived_brightness())
  unchecked_color(y, y, y)
}

///|
pub fn Color::distance_rgb(self : Color, other : Color) -> Double {
  let dr = self.r.to_double() - other.r.to_double()
  let dg = self.g.to_double() - other.g.to_double()
  let db = self.b.to_double() - other.b.to_double()
  @math.pow(dr * dr + dg * dg + db * db, 0.5)
}

///|
pub fn Color::weighted_distance(self : Color, other : Color) -> Double {
  let dr = (self.r - other.r).to_double()
  let dg = (self.g - other.g).to_double()
  let db = (self.b - other.b).to_double()
  @math.pow(dr * dr * 0.30 + dg * dg * 0.59 + db * db * 0.11, 0.5)
}

///|
pub fn Color::channel_spread(self : Color) -> Int {
  max_int(self.r, max_int(self.g, self.b)) -
  min_int(self.r, min_int(self.g, self.b))
}

///|
pub fn Color::to_hsl(self : Color) -> Hsl {
  let r = self.r.to_double() / 255.0
  let g = self.g.to_double() / 255.0
  let b = self.b.to_double() / 255.0
  let maxc = max_double(r, max_double(g, b))
  let minc = min_double(r, min_double(g, b))
  let l = (maxc + minc) / 2.0
  let delta = maxc - minc
  if delta == 0.0 {
    { h: 0.0, s: 0.0, l }
  } else {
    let s = if l > 0.5 {
      delta / (2.0 - maxc - minc)
    } else {
      delta / (maxc + minc)
    }
    let raw_h = if maxc == r {
      let offset = if g < b { 6.0 } else { 0.0 }
      (g - b) / delta + offset
    } else if maxc == g {
      (b - r) / delta + 2.0
    } else {
      (r - g) / delta + 4.0
    }
    { h: raw_h * 60.0, s, l }
  }
}

///|
pub fn Hsl::to_color(self : Hsl) -> Color {
  let h = normalize_hue(self.h) / 360.0
  let s = clamp_double(self.s, 0.0, 1.0)
  let l = clamp_double(self.l, 0.0, 1.0)
  if s == 0.0 {
    let c = round_channel(l * 255.0)
    unchecked_color(c, c, c)
  } else {
    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }
    let p = 2.0 * l - q
    unchecked_color(
      round_channel(hue_to_rgb(p, q, h + 1.0 / 3.0) * 255.0),
      round_channel(hue_to_rgb(p, q, h) * 255.0),
      round_channel(hue_to_rgb(p, q, h - 1.0 / 3.0) * 255.0),
    )
  }
}

///|
pub fn Hsl::rotate(self : Hsl, degrees : Double) -> Hsl {
  { h: normalize_hue(self.h + degrees), s: self.s, l: self.l }
}

///|
pub fn Hsl::with_lightness(self : Hsl, lightness : Double) -> Hsl {
  { h: self.h, s: self.s, l: clamp_double(lightness, 0.0, 1.0) }
}

///|
pub fn Hsl::with_saturation(self : Hsl, saturation : Double) -> Hsl {
  { h: self.h, s: clamp_double(saturation, 0.0, 1.0), l: self.l }
}

///|
pub fn Color::rotate_hue(self : Color, degrees : Double) -> Color {
  self.to_hsl().rotate(degrees).to_color()
}

///|
pub fn Color::set_lightness(self : Color, lightness : Double) -> Color {
  self.to_hsl().with_lightness(lightness).to_color()
}

///|
pub fn Color::set_saturation(self : Color, saturation : Double) -> Color {
  self.to_hsl().with_saturation(saturation).to_color()
}

///|
pub fn Color::saturate(self : Color, amount : Double) -> Color {
  let hsl = self.to_hsl()
  hsl.with_saturation(hsl.s + amount).to_color()
}

///|
pub fn Color::desaturate(self : Color, amount : Double) -> Color {
  let hsl = self.to_hsl()
  hsl.with_saturation(hsl.s - amount).to_color()
}

///|
pub fn Color::harmonies(self : Color, kind : HarmonyKind) -> Array[Color] {
  match kind {
    HarmonyComplementary => [self, self.rotate_hue(180.0)]
    HarmonyAnalogous => [self.rotate_hue(-30.0), self, self.rotate_hue(30.0)]
    HarmonyTriadic => [self, self.rotate_hue(120.0), self.rotate_hue(240.0)]
    HarmonySplitComplementary =>
      [self, self.rotate_hue(150.0), self.rotate_hue(210.0)]
    HarmonyMonochrome =>
      [
        self.set_lightness(0.18),
        self.set_lightness(0.38),
        self.set_lightness(0.58),
        self.set_lightness(0.78),
      ]
    HarmonyCustom => [self]
  }
}

///|
pub fn readable_foreground(
  background : Color,
  policy : Policy,
) -> RepairCandidate {
  let black = unchecked_color(0, 0, 0)
  let white = unchecked_color(255, 255, 255)
  let required = minimum_ratio(policy)
  let black_ratio = contrast_ratio(black, background)
  let white_ratio = contrast_ratio(white, background)
  if black_ratio >= required && black_ratio >= white_ratio {
    {
      color: black,
      ratio: black_ratio,
      delta: 0.0,
      strategy: RepairUseIdealText,
      steps: 0,
    }
  } else if white_ratio >= required {
    {
      color: white,
      ratio: white_ratio,
      delta: 0.0,
      strategy: RepairUseIdealText,
      steps: 0,
    }
  } else if black_ratio >= white_ratio {
    {
      color: black,
      ratio: black_ratio,
      delta: 0.0,
      strategy: RepairUseIdealText,
      steps: 0,
    }
  } else {
    {
      color: white,
      ratio: white_ratio,
      delta: 0.0,
      strategy: RepairUseIdealText,
      steps: 0,
    }
  }
}

///|
pub fn repair_foreground(
  foreground : Color,
  background : Color,
  policy : Policy,
) -> RepairCandidate {
  let required = minimum_ratio(policy)
  let current = contrast_ratio(foreground, background)
  if current >= required {
    return {
      color: foreground,
      ratio: current,
      delta: 0.0,
      strategy: RepairKeep,
      steps: 0,
    }
  }
  let darker = search_foreground_path(
    foreground,
    background,
    required,
    unchecked_color(0, 0, 0),
    RepairDarkenForeground,
  )
  let lighter = search_foreground_path(
    foreground,
    background,
    required,
    unchecked_color(255, 255, 255),
    RepairLightenForeground,
  )
  choose_better_candidate(darker, lighter, required)
}

///|
pub fn repair_background(
  foreground : Color,
  background : Color,
  policy : Policy,
) -> RepairCandidate {
  let required = minimum_ratio(policy)
  let current = contrast_ratio(foreground, background)
  if current >= required {
    return {
      color: background,
      ratio: current,
      delta: 0.0,
      strategy: RepairKeep,
      steps: 0,
    }
  }
  let darker = search_background_path(
    foreground,
    background,
    required,
    unchecked_color(0, 0, 0),
    RepairDarkenBackground,
  )
  let lighter = search_background_path(
    foreground,
    background,
    required,
    unchecked_color(255, 255, 255),
    RepairLightenBackground,
  )
  choose_better_candidate(darker, lighter, required)
}

///|
pub fn swatch_profile(s : Swatch) -> ColorProfile {
  s.color.profile()
}

///|
pub fn palette_stats(swatches : Array[Swatch]) -> PaletteStats {
  let mut foregrounds = 0
  let mut backgrounds = 0
  let mut borders = 0
  let mut luminance = 0.0
  let mut darkest : Swatch? = None
  let mut lightest : Swatch? = None
  for s in swatches {
    if s.role.is_foreground() {
      foregrounds += 1
    }
    if s.role.is_background() {
      backgrounds += 1
    }
    if s.role == RoleBorder {
      borders += 1
    }
    let lum = s.color.relative_luminance()
    luminance += lum
    match darkest {
      None => darkest = Some(s)
      Some(current) =>
        if lum < current.color.relative_luminance() {
          darkest = Some(s)
        }
    }
    match lightest {
      None => lightest = Some(s)
      Some(current) =>
        if lum > current.color.relative_luminance() {
          lightest = Some(s)
        }
    }
  }
  let avg = if swatches.length() == 0 {
    0.0
  } else {
    luminance / swatches.length().to_double()
  }
  {
    swatches: swatches.length(),
    foregrounds,
    backgrounds,
    borders,
    average_luminance: avg,
    darkest,
    lightest,
  }
}

///|
pub fn channel_stats(
  swatches : Array[Swatch],
) -> (ChannelStats, ChannelStats, ChannelStats) {
  if swatches.length() == 0 {
    let empty = { min: 0, max: 0, average: 0.0 }
    return (empty, empty, empty)
  }
  let mut min_r = 255
  let mut min_g = 255
  let mut min_b = 255
  let mut max_r = 0
  let mut max_g = 0
  let mut max_b = 0
  let mut sum_r = 0
  let mut sum_g = 0
  let mut sum_b = 0
  for s in swatches {
    min_r = min_int(min_r, s.color.r)
    min_g = min_int(min_g, s.color.g)
    min_b = min_int(min_b, s.color.b)
    max_r = max_int(max_r, s.color.r)
    max_g = max_int(max_g, s.color.g)
    max_b = max_int(max_b, s.color.b)
    sum_r += s.color.r
    sum_g += s.color.g
    sum_b += s.color.b
  }
  let n = swatches.length().to_double()
  (
    { min: min_r, max: max_r, average: sum_r.to_double() / n },
    { min: min_g, max: max_g, average: sum_g.to_double() / n },
    { min: min_b, max: max_b, average: sum_b.to_double() / n },
  )
}

///|
pub fn audit_token_document(input : String, policy : Policy) -> DocumentAudit {
  let tokens = parse_token_document(input)
  let palette = audit_palette(tokens.swatches, policy)
  let stats = palette_stats(tokens.swatches)
  { tokens, palette, stats }
}

///|
pub fn parse_token_document(input : String) -> TokenParseReport {
  let swatches : Array[Swatch] = []
  let diagnostics : Array[TokenDiagnostic] = []
  let mut ignored = 0
  let lines = [ for line in input.split("\n") => line.to_owned() ]
  for index, raw in lines {
    let line_no = index + 1
    let line = raw.trim().to_owned()
    if should_ignore_token_line(line) {
      ignored += 1
    } else {
      parse_token_line(line, line_no, swatches, diagnostics)
    }
  }
  { swatches, diagnostics, ignored }
}

///|
pub fn TokenParseReport::has_errors(self : TokenParseReport) -> Bool {
  for diagnostic in self.diagnostics {
    if diagnostic.severity == SeverityError {
      return true
    }
  }
  false
}

///|
pub fn TokenParseReport::to_markdown(self : TokenParseReport) -> String {
  let out = StringBuilder()
  out.write_string("# PaletteGuard token parse\n\n")
  out.write_string("Swatches: " + self.swatches.length().to_string() + "\n")
  out.write_string(
    "Diagnostics: " + self.diagnostics.length().to_string() + "\n",
  )
  out.write_string("Ignored lines: " + self.ignored.to_string() + "\n\n")
  if self.diagnostics.length() > 0 {
    out.write_string("| line | column | severity | token | message |\n")
    out.write_string("| ---: | ---: | --- | --- | --- |\n")
    for diagnostic in self.diagnostics {
      out.write_string("| ")
      out.write_string(diagnostic.line.to_string())
      out.write_string(" | ")
      out.write_string(diagnostic.column.to_string())
      out.write_string(" | ")
      out.write_string(diagnostic.severity.label())
      out.write_string(" | ")
      out.write_string(diagnostic.token)
      out.write_string(" | ")
      out.write_string(diagnostic.message)
      out.write_string(" |\n")
    }
  }
  out.to_string()
}

///|
pub fn PaletteStats::to_markdown(self : PaletteStats) -> String {
  let out = StringBuilder()
  out.write_string("# PaletteGuard palette stats\n\n")
  out.write_string("- swatches: " + self.swatches.to_string() + "\n")
  out.write_string("- foregrounds: " + self.foregrounds.to_string() + "\n")
  out.write_string("- backgrounds: " + self.backgrounds.to_string() + "\n")
  out.write_string("- borders: " + self.borders.to_string() + "\n")
  out.write_string(
    "- average luminance: " + format_ratio(self.average_luminance) + "\n",
  )
  match self.darkest {
    None => out.write_string("- darkest: n/a\n")
    Some(s) =>
      out.write_string("- darkest: " + s.name + " " + s.color.to_hex() + "\n")
  }
  match self.lightest {
    None => out.write_string("- lightest: n/a\n")
    Some(s) =>
      out.write_string("- lightest: " + s.name + " " + s.color.to_hex() + "\n")
  }
  out.to_string()
}

///|
pub fn DocumentAudit::to_markdown(self : DocumentAudit) -> String {
  self.tokens.to_markdown() +
  "\n" +
  self.stats.to_markdown() +
  "\n" +
  self.palette.to_markdown()
}

///|
pub fn audit_ramp(family : String, swatches : Array[Swatch]) -> RampAudit {
  let duplicates : Array[String] = []
  let mut monotonic = true
  let mut direction = 0
  let mut previous : Swatch? = None
  let mut smallest_gap = 1.0
  for index, s in swatches {
    for j in 0.. previous = Some(s)
      Some(last) => {
        let gap = s.color.relative_luminance() - last.color.relative_luminance()
        if gap > 0.0 {
          if direction < 0 {
            monotonic = false
          } else {
            direction = 1
          }
        } else if gap < 0.0 {
          if direction > 0 {
            monotonic = false
          } else {
            direction = -1
          }
        }
        smallest_gap = min_double(smallest_gap, abs_double(gap))
        previous = Some(s)
      }
    }
  }
  if swatches.length() <= 1 {
    smallest_gap = 0.0
  }
  {
    family,
    swatches,
    monotonic_luminance: monotonic,
    duplicate_hexes: duplicates,
    smallest_luminance_gap: smallest_gap,
  }
}

///|
pub fn RampAudit::to_markdown(self : RampAudit) -> String {
  let out = StringBuilder()
  out.write_string("# PaletteGuard ramp audit\n\n")
  out.write_string("Family: " + self.family + "\n")
  out.write_string("Swatches: " + self.swatches.length().to_string() + "\n")
  out.write_string("Monotonic luminance: ")
  out.write_string(if self.monotonic_luminance { "yes" } else { "no" })
  out.write_string("\n")
  out.write_string(
    "Smallest luminance gap: " +
    format_ratio(self.smallest_luminance_gap) +
    "\n",
  )
  if self.duplicate_hexes.length() > 0 {
    out.write_string(
      "Duplicate colors: " + self.duplicate_hexes.join(", ") + "\n",
    )
  } else {
    out.write_string("Duplicate colors: none\n")
  }
  out.to_string()
}

///|
pub fn contrast_matrix(swatches : Array[Swatch], policy : Policy) -> String {
  let out = StringBuilder()
  out.write_string("| foreground/background |")
  for bg in swatches {
    if bg.role.is_background() {
      out.write_string(" " + bg.name + " |")
    }
  }
  out.write_string("\n| --- |")
  for bg in swatches {
    if bg.role.is_background() {
      ignore(bg)
      out.write_string(" ---: |")
    }
  }
  out.write_string("\n")
  for fg in swatches {
    if fg.role.is_foreground() {
      out.write_string("| " + fg.name + " |")
      for bg in swatches {
        if bg.role.is_background() {
          let pair = audit_pair(fg, bg, policy)
          out.write_string(" " + format_ratio(pair.ratio))
          out.write_string(if pair.passed { " pass |" } else { " fail |" })
        }
      }
      out.write_string("\n")
    }
  }
  out.to_string()
}

///|
pub fn parse_role_token(token : String) -> Role? {
  let lower = token.trim().to_lower().to_owned()
  if lower.contains("text") || lower.contains("fg") || lower.contains("ink") {
    Some(RoleText)
  } else if lower.contains("accent") ||
    lower.contains("brand") ||
    lower.contains("link") {
    Some(RoleAccent)
  } else if lower.contains("background") ||
    lower.contains("bg") ||
    lower.contains("canvas") {
    Some(RoleBackground)
  } else if lower.contains("surface") ||
    lower.contains("panel") ||
    lower.contains("card") {
    Some(RoleSurface)
  } else if lower.contains("border") ||
    lower.contains("line") ||
    lower.contains("stroke") {
    Some(RoleBorder)
  } else {
    None
  }
}

///|
fn parse_token_line(
  line : String,
  line_no : Int,
  swatches : Array[Swatch],
  diagnostics : Array[TokenDiagnostic],
) -> Unit {
  let parts = split_assignment(line)
  if parts.length() != 2 {
    diagnostics.push(
      token_diagnostic(
        line_no,
        1,
        line,
        SeverityError,
        DiagnosticMissingAssignment,
        "expected token assignment with ':' or '='",
      ),
    )
    return
  }
  let name = parts[0].trim().to_owned()
  let value = parts[1].trim().to_owned()
  if name.length() == 0 {
    diagnostics.push(
      token_diagnostic(
        line_no,
        1,
        line,
        SeverityError,
        DiagnosticEmptyName,
        "token name cannot be empty",
      ),
    )
    return
  }
  match parse_role_token(name) {
    None =>
      diagnostics.push(
        token_diagnostic(
          line_no,
          1,
          name,
          SeverityWarning,
          DiagnosticUnknownRole,
          "role could not be inferred; token ignored",
        ),
      )
    Some(role) =>
      match parse_color(value) {
        ColorParsed(color) => swatches.push(swatch(name, color, role))
        ColorParseFailed(error) =>
          diagnostics.push(
            token_diagnostic(
              line_no,
              max_int(1, find_assignment_column(line) + 1),
              value,
              SeverityError,
              DiagnosticBadColor(error),
              "color value could not be parsed",
            ),
          )
      }
  }
}

///|
fn token_diagnostic(
  line : Int,
  column : Int,
  token : String,
  severity : Severity,
  kind : TokenDiagnosticKind,
  message : String,
) -> TokenDiagnostic {
  { line, column, token, severity, kind, message }
}

///|
fn split_assignment(line : String) -> Array[String] {
  if line.contains("=") {
    [
      for part in line.split("=") => part.to_owned()
    ]
  } else if line.contains(":") {
    [
      for part in line.split(":") => part.to_owned()
    ]
  } else {
    [line]
  }
}

///|
fn find_assignment_column(line : String) -> Int {
  let mut index = 0
  for ch in line.iter() {
    if ch == '=' || ch == ':' {
      return index
    }
    index += 1
  }
  0
}

///|
fn should_ignore_token_line(line : String) -> Bool {
  line.length() == 0 ||
  starts_with_string(line, "//") ||
  starts_with_string(line, "# ") ||
  starts_with_string(line, "--")
}

///|
fn starts_with_string(text : String, prefix : String) -> Bool {
  if prefix.length() > text.length() {
    return false
  }
  text[:prefix.length()].to_owned() == prefix
}

///|
fn search_foreground_path(
  foreground : Color,
  background : Color,
  required : Double,
  target : Color,
  strategy : RepairStrategy,
) -> RepairCandidate {
  let mut best = {
    color: target,
    ratio: contrast_ratio(target, background),
    delta: foreground.distance_rgb(target),
    strategy,
    steps: 100,
  }
  for step in 1..<=100 {
    let amount = step.to_double() / 100.0
    let candidate = foreground.mix(target, amount)
    let ratio = contrast_ratio(candidate, background)
    if ratio >= required {
      return {
        color: candidate,
        ratio,
        delta: foreground.distance_rgb(candidate),
        strategy,
        steps: step,
      }
    }
    if ratio > best.ratio {
      best = {
        color: candidate,
        ratio,
        delta: foreground.distance_rgb(candidate),
        strategy,
        steps: step,
      }
    }
  }
  best
}

///|
fn search_background_path(
  foreground : Color,
  background : Color,
  required : Double,
  target : Color,
  strategy : RepairStrategy,
) -> RepairCandidate {
  let mut best = {
    color: target,
    ratio: contrast_ratio(foreground, target),
    delta: background.distance_rgb(target),
    strategy,
    steps: 100,
  }
  for step in 1..<=100 {
    let amount = step.to_double() / 100.0
    let candidate = background.mix(target, amount)
    let ratio = contrast_ratio(foreground, candidate)
    if ratio >= required {
      return {
        color: candidate,
        ratio,
        delta: background.distance_rgb(candidate),
        strategy,
        steps: step,
      }
    }
    if ratio > best.ratio {
      best = {
        color: candidate,
        ratio,
        delta: background.distance_rgb(candidate),
        strategy,
        steps: step,
      }
    }
  }
  best
}

///|
fn choose_better_candidate(
  a : RepairCandidate,
  b : RepairCandidate,
  required : Double,
) -> RepairCandidate {
  let a_passes = a.ratio >= required
  let b_passes = b.ratio >= required
  if a_passes && !b_passes {
    a
  } else if b_passes && !a_passes {
    b
  } else if a_passes {
    if a.delta <= b.delta {
      a
    } else {
      b
    }
  } else if a.ratio > b.ratio {
    a
  } else if b.ratio > a.ratio {
    b
  } else if a.delta <= b.delta {
    a
  } else {
    b
  }
}

///|
fn hue_to_rgb(p : Double, q : Double, t : Double) -> Double {
  let mut x = t
  while x < 0.0 {
    x += 1.0
  }
  while x > 1.0 {
    x -= 1.0
  }
  if x < 1.0 / 6.0 {
    p + (q - p) * 6.0 * x
  } else if x < 1.0 / 2.0 {
    q
  } else if x < 2.0 / 3.0 {
    p + (q - p) * (2.0 / 3.0 - x) * 6.0
  } else {
    p
  }
}

///|
fn normalize_hue(value : Double) -> Double {
  let mut h = value
  while h < 0.0 {
    h += 360.0
  }
  while h >= 360.0 {
    h -= 360.0
  }
  h
}

///|
fn round_channel(value : Double) -> Int {
  clamp_int(@math.round(value).to_int(), 0, 255)
}

///|
fn clamp_int(value : Int, low : Int, high : Int) -> Int {
  if value < low {
    low
  } else if value > high {
    high
  } else {
    value
  }
}

///|
fn clamp_double(value : Double, low : Double, high : Double) -> Double {
  if value < low {
    low
  } else if value > high {
    high
  } else {
    value
  }
}

///|
fn max_int(a : Int, b : Int) -> Int {
  if a >= b {
    a
  } else {
    b
  }
}

///|
fn min_int(a : Int, b : Int) -> Int {
  if a <= b {
    a
  } else {
    b
  }
}

///|
fn max_double(a : Double, b : Double) -> Double {
  if a >= b {
    a
  } else {
    b
  }
}

///|
fn min_double(a : Double, b : Double) -> Double {
  if a <= b {
    a
  } else {
    b
  }
}

///|
fn abs_double(value : Double) -> Double {
  if value < 0.0 {
    -value
  } else {
    value
  }
}