// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

// Writing a colour as CSS.
//
// The rule throughout is the shortest spelling that reads back as the same
// colour. Three decisions follow from it and are worth stating, because each of
// them could go the other way:
//
//   * Integers drop their decimal point, and everything else is rounded to five
//     places with trailing zeros trimmed. Five is where the numbers a colour is
//     made of stop mattering — an 8-bit channel needs two — and it is what the
//     `color()` examples in CSS Color 4 print.
//   * A component that is not a finite number is written as `none`, which is
//     CSS's own way of saying the value does not exist. That is what a missing
//     component is, and encoding must not invent a zero for it.
//   * `rgb()`, `hsl()` and `hwb()` clamp to the range their syntax allows,
//     because a reader would clamp anyway; `lab()`, `oklch()` and `color()`
//     do not, because a colour outside a gamut is a real colour and losing it
//     on the way out would be worse than a long string.

///|
/// How to write a colour out.
pub(all) enum Format {
  /// The shortest spelling the colour's own space allows, falling back to sRGB
  /// for the spaces CSS has no syntax for.
  Shortest
  /// `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`, whichever is shortest and exact.
  /// Always sRGB, so a colour in another space is converted and may be clipped.
  Hex
  /// `rgb(0 0 0)` or `rgb(0 0 0 / 0.5)`, always sRGB.
  Rgb
  /// `hsl(0 0% 0%)`.
  Hsl
  /// `hwb(0 0% 0%)`.
  Hwb
  /// `lab(0 0 0)`.
  Lab
  /// `lch(0 0 0)`.
  Lch
  /// `oklab(0 0 0)`.
  Oklab
  /// `oklch(0 0 0)`.
  Oklch
  /// `color(srgb 0 0 0)` and the rest of the spaces `color()` takes. A space it
  /// cannot name is written in its own syntax instead.
  Color
  /// The name the colour has, or the same string `Hex` would give.
  Named
} derive(Eq, Debug)

///|
pub extend Format with Eq::{equal, not_equal}

///|
pub extend Format with Debug::{to_repr}

///|
/// A format writes itself with the name a caller would use for it.
pub impl Show for Format with fn output(self, logger) {
  logger.write_string(
    match self {
      Shortest => "shortest"
      Hex => "hex"
      Rgb => "rgb"
      Hsl => "hsl"
      Hwb => "hwb"
      Lab => "lab"
      Lch => "lch"
      Oklab => "oklab"
      Oklch => "oklch"
      Color => "color"
      Named => "named"
    },
  )
}

///|
pub extend Format with Show::{to_string, output}

///|
/// Five places, which is where a colour stops changing.
let decimals : Double = 100000.0

///|
/// A decimal number as CSS writes it: no exponent, no trailing zeros, and
/// `none` where there is no number to write.
fn decimal(value : Double) -> String {
  if value.is_nan() ||
    value == @double.infinity ||
    value == @double.neg_infinity {
    return "none"
  }
  let rounded = @math.round(value * decimals) / decimals
  let text = rounded.to_string()
  // Rounding can leave a negative zero behind, and `-0` in a colour reads as a
  // mistake even though it is the same number.
  if text == "-0" {
    "0"
  } else {
    text
  }
}

///|
/// A hue, which is an angle rather than a quantity: rounding 359.999996 must
/// write 0 rather than 360, because 360 is a whole turn and a reader that
/// normalises it would be right to, leaving the two spellings a full turn apart.
fn hue_text(value : Double) -> String {
  let text = decimal(value)
  if text == "360" {
    "0"
  } else {
    text
  }
}

///|
/// A percentage, which is how `hsl()` and `hwb()` write saturation, lightness,
/// whiteness and blackness.
fn percentage(value : Double) -> String {
  let text = decimal(value * 100.0)
  // A missing component is `none`, and `none%` is not a thing.
  if text == "none" {
    text
  } else {
    text + "%"
  }
}

///|
/// One hexadecimal digit.
fn hex_digit(value : Int32) -> Char {
  let digits = "0123456789abcdef"
  digits.unsafe_get(value).unsafe_to_char()
}

///|
/// A component rounded to a channel byte, clamped: a byte has no room outside
/// 0–255 and a hexadecimal colour has no way to say so.
fn byte_of(value : Double) -> Int32 {
  if value.is_nan() {
    return 0
  }
  let scaled = @math.round(value * 255.0)
  if scaled <= 0.0 {
    0
  } else if scaled >= 255.0 {
    255
  } else {
    scaled.to_int()
  }
}

///|
/// Whether a channel is a multiple of 17, which is what lets `#rgb` stand in
/// for `#rrggbb`: the short form repeats each digit.
fn is_short_byte(value : Int32) -> Bool {
  value % 17 == 0
}

///|
fn write_hex_pairs(
  out : StringBuilder,
  r : Int32,
  g : Int32,
  b : Int32,
  a : Int32?,
) -> Unit {
  out.write_char(hex_digit(r / 16))
  out.write_char(hex_digit(r % 16))
  out.write_char(hex_digit(g / 16))
  out.write_char(hex_digit(g % 16))
  out.write_char(hex_digit(b / 16))
  out.write_char(hex_digit(b % 16))
  match a {
    Some(a) => {
      out.write_char(hex_digit(a / 16))
      out.write_char(hex_digit(a % 16))
    }
    None => ()
  }
}

///|
/// The shortest hexadecimal spelling that keeps the colour exactly.
///
/// Exactness is what decides between `#f00` and `#ff0000`: the short form means
/// the same only when each channel repeats a digit, and when a channel is not a
/// whole byte at all no hexadecimal spelling is exact — the caller then writes
/// `rgb()` instead.
fn hex_text(color : Color) -> String {
  let srgb = color.to_srgb()
  let (r, g, b) = (byte_of(srgb.x), byte_of(srgb.y), byte_of(srgb.z))
  let alpha = if srgb.alpha >= 1.0 {
    None
  } else if srgb.alpha <= 0.0 {
    Some(0)
  } else {
    Some(byte_of(srgb.alpha))
  }
  let out = StringBuilder()
  out.write_char('#')
  let short = is_short_byte(r) && is_short_byte(g) && is_short_byte(b)
  let short_alpha = match alpha {
    None => true
    Some(a) => is_short_byte(a)
  }
  if short && short_alpha {
    out.write_char(hex_digit(r / 17))
    out.write_char(hex_digit(g / 17))
    out.write_char(hex_digit(b / 17))
    match alpha {
      Some(a) => out.write_char(hex_digit(a / 17))
      None => ()
    }
  } else {
    write_hex_pairs(out, r, g, b, alpha)
  }
  out.to_string()
}

///|
/// Whether every channel and the alpha are whole bytes, and so whether a
/// hexadecimal spelling would keep the colour exactly. `0.5` alpha is not: its
/// byte is 127.5, and `#...80` would be a different colour.
fn is_byte_exact(color : Color) -> Bool {
  let (r, g, b) = color.components()
  let is_whole_byte = fn(value : Double) {
    let scaled = value * 255.0
    (scaled - @math.round(scaled)).abs() < 1.0e-9
  }
  is_whole_byte(r) &&
  is_whole_byte(g) &&
  is_whole_byte(b) &&
  is_whole_byte(color.alpha)
}

///|
/// `rgb()` with the alpha left out when it is one.
fn rgb_text(color : Color) -> String {
  let srgb = color.to_srgb()
  let out = StringBuilder()
  out.write_string("rgb(")
  out.write_string(decimal(clamp01(srgb.x) * 255.0))
  out.write_char(' ')
  out.write_string(decimal(clamp01(srgb.y) * 255.0))
  out.write_char(' ')
  out.write_string(decimal(clamp01(srgb.z) * 255.0))
  write_alpha(out, srgb.alpha)
  out.write_char(')')
  out.to_string()
}

///|
/// The ` / alpha` tail every function syntax shares, or nothing when the colour
/// is opaque.
fn write_alpha(out : StringBuilder, alpha : Double) -> Unit {
  if alpha >= 1.0 {
    return
  }
  out.write_string(" / ")
  out.write_string(decimal(if alpha <= 0.0 { 0.0 } else { alpha }))
}

///|
/// A colour in a function syntax: the name, the three components as already
/// written strings, and the alpha.
fn function_text(
  name : StringView,
  first : String,
  second : String,
  third : String,
  alpha : Double,
) -> String {
  let out = StringBuilder()
  out.write_view(name)
  out.write_char('(')
  out.write_string(first)
  out.write_char(' ')
  out.write_string(second)
  out.write_char(' ')
  out.write_string(third)
  write_alpha(out, alpha)
  out.write_char(')')
  out.to_string()
}

///|
/// `hsl()`, with saturation and lightness clamped because the syntax cannot
/// carry anything else.
fn hsl_text(color : Color) -> String {
  let hsl = color.to_hsl()
  function_text(
    "hsl",
    hue_text(hsl.x),
    percentage(clamp01(hsl.y)),
    percentage(clamp01(hsl.z)),
    hsl.alpha,
  )
}

///|
fn hwb_text(color : Color) -> String {
  let hwb = color.to_hwb()
  function_text(
    "hwb",
    hue_text(hwb.x),
    percentage(clamp01(hwb.y)),
    percentage(clamp01(hwb.z)),
    hwb.alpha,
  )
}

///|
fn lab_text(color : Color) -> String {
  let lab = color.to_lab()
  function_text(
    "lab",
    decimal(lab.x),
    decimal(lab.y),
    decimal(lab.z),
    lab.alpha,
  )
}

///|
fn lch_text(color : Color) -> String {
  let lch = color.to_lch()
  function_text(
    "lch",
    decimal(lch.x),
    decimal(lch.y),
    hue_text(lch.z),
    lch.alpha,
  )
}

///|
fn oklab_text(color : Color) -> String {
  let oklab = color.to_oklab()
  function_text(
    "oklab",
    decimal(oklab.x),
    decimal(oklab.y),
    decimal(oklab.z),
    oklab.alpha,
  )
}

///|
fn oklch_text(color : Color) -> String {
  let oklch = color.to_oklch()
  function_text(
    "oklch",
    decimal(oklch.x),
    decimal(oklch.y),
    hue_text(oklch.z),
    oklch.alpha,
  )
}

///|
/// Whether every component and the alpha are inside 0–1, which is the range the
/// sRGB syntaxes can carry without clamping.
fn is_unit_ranged(color : Color) -> Bool {
  let (r, g, b) = color.components()
  // The same slack the gamut check uses: a conversion that lands a hair over
  // one has not left the gamut, and writing it as `color(srgb 1 0 0)` because of
  // the last bit of a double would be a strange thing to do.
  let inside = fn(value : Double) {
    if value.is_nan() {
      return true
    }
    value >= -1.0e-6 && value <= 1.0 + 1.0e-6
  }
  inside(r) && inside(g) && inside(b) && inside(color.alpha)
}

///|
/// An sRGB colour in whichever spelling is shortest and exact: hex when every
/// channel is a whole byte, `rgb()` when they are fractions, and `color(srgb)`
/// when a component is outside 0–1 and `rgb()` would clamp it away.
fn srgb_text(color : Color) -> String {
  let srgb = color.to_srgb()
  if !is_unit_ranged(srgb) {
    color_syntax(srgb)
  } else if is_byte_exact(srgb) {
    hex_text(srgb)
  } else {
    rgb_text(srgb)
  }
}

///|
/// A colour as CSS, in the shortest spelling that reads back as the same colour.
///
/// Without a format the colour stays in the space it is in, which matters: an
/// OkLCh colour written through sRGB would be clipped to a gamut it never asked
/// for. The spaces CSS cannot spell — HSV, YCbCr — go out as sRGB, and the
/// unresolved `currentcolor` goes out as itself.
pub fn Color::encode(self : Color, format? : Format) -> String {
  // `currentcolor` has no components to write, so no format can apply to it:
  // writing it as a hexadecimal colour would resolve it to black, which is a
  // different declaration.
  if self.space is Current {
    return "currentcolor"
  }
  match format {
    Some(Hex) => hex_text(self)
    Some(Rgb) => rgb_text(self)
    Some(Hsl) => hsl_text(self)
    Some(Hwb) => hwb_text(self)
    Some(Lab) => lab_text(self)
    Some(Lch) => lch_text(self)
    Some(Oklab) => oklab_text(self)
    Some(Oklch) => oklch_text(self)
    Some(Color) =>
      if self.space.is_predefined() {
        color_syntax(self)
      } else {
        self.natural_text()
      }
    Some(Named) =>
      match self.name() {
        Some(name) => name
        None => hex_text(self)
      }
    Some(Shortest) | None => self.natural_text()
  }
}

///|
fn Color::natural_text(self : Color) -> String {
  match self.space {
    Srgb => srgb_text(self)
    Hsl => hsl_text(self)
    Hwb => hwb_text(self)
    Lab => lab_text(self)
    Lch => lch_text(self)
    Oklab => oklab_text(self)
    Oklch => oklch_text(self)
    _ =>
      if self.space.is_predefined() {
        color_syntax(self)
      } else {
        // HSV and YCbCr have no CSS syntax, and inventing one would produce a
        // string no reader accepts; sRGB is the one every consumer understands.
        srgb_text(self)
      }
  }
}

///|
/// `color( c1 c2 c3 / a)`.
fn color_syntax(color : Color) -> String {
  let out = StringBuilder()
  out.write_string("color(")
  out.write_string(color.space.code())
  out.write_char(' ')
  out.write_string(decimal(color.x))
  out.write_char(' ')
  out.write_string(decimal(color.y))
  out.write_char(' ')
  out.write_string(decimal(color.z))
  write_alpha(out, color.alpha)
  out.write_char(')')
  out.to_string()
}

///|
/// A colour writes itself as CSS, so that `\{color}` in a log or an error is
/// something a browser would accept.
pub impl Show for Color with fn output(self, logger) {
  logger.write_string(self.encode())
}

///|
pub extend Color with Show::{to_string, output}