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

// Mixing two colours, and nudging one.
//
// `color-mix()` is not a weighted average of RGB triples. CSS Color 5 says where
// the average is taken — the interpolation space decides, and Oklab, the
// default, is the one that keeps a red-to-blue mix from going through mud. It
// says how alpha takes part: premultiplied, so that a colour at 10% opacity
// pulls a tenth as hard as an opaque one rather than dragging the hue of the
// result with it. And in a polar space it says which way round the hue circle to
// travel, because halfway between 350° and 10° is not 180°.
//
// The adjustments at the bottom are the same arithmetic with one end fixed:
// lighten is a mix towards white in the space's own lightness, and saturate is a
// mix towards more chroma.

///|
/// Which way round the hue circle an interpolation travels.
pub(all) enum Hue {
  /// The way that is at most half a turn: 350° to 10° goes through 0°.
  Shorter
  /// The way that is at least half a turn.
  Longer
  /// Always increasing, so 350° to 10° goes through 360°.
  Increasing
  /// Always decreasing.
  Decreasing
} derive(Eq, Debug)

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

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

///|
/// A direction writes itself the way `color-mix()` spells it.
pub impl Show for Hue with fn output(self, logger) {
  logger.write_string(
    match self {
      Shorter => "shorter"
      Longer => "longer"
      Increasing => "increasing"
      Decreasing => "decreasing"
    },
  )
}

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

///|
/// Where a space keeps its hue, if it has one.
fn Space::hue_index(self : Space) -> Int32? {
  match self {
    Hsl | Hsv | Hwb => Some(0)
    Lch | Oklch => Some(2)
    _ => None
  }
}

///|
/// The top of a space's lightness scale, which is what an adjustment of `0.1`
/// means a tenth of.
fn Space::lightness_range(self : Space) -> Double {
  match self {
    Lab | Lch => 100.0
    _ => 1.0
  }
}

///|
/// The chroma a space calls full, which is what an adjustment of `0.1` means a
/// tenth of. `lch()` writes 150 and `oklch()` 0.4 as the chroma of a colour at
/// the edge of what people can see.
fn Space::chroma_range(self : Space) -> Double {
  match self {
    Lch => 150.0
    Oklch => 0.4
    _ => 1.0
  }
}

///|
/// One component of a triple, by index.
fn component_of(triple : (Double, Double, Double), index : Int32) -> Double {
  match index {
    0 => triple.0
    1 => triple.1
    _ => triple.2
  }
}

///|
/// The hue halfway between two, in the direction asked for.
fn interpolate_hue(
  first : Double,
  second : Double,
  weight : Double,
  hue : Hue,
) -> Double {
  if first.is_nan() || second.is_nan() {
    return @double.not_a_number
  }
  let first = hue_of(first)
  let second = hue_of(second)
  let forward = match hue {
    Shorter =>
      if second - first > 180.0 {
        second - 360.0
      } else if second - first < -180.0 {
        second + 360.0
      } else {
        second
      }
    Longer =>
      if second - first > 0.0 && second - first < 180.0 {
        second - 360.0
      } else if second - first > -180.0 && second - first <= 0.0 {
        second + 360.0
      } else {
        second
      }
    Increasing => if second < first { second + 360.0 } else { second }
    Decreasing => if second > first { second - 360.0 } else { second }
  }
  let mixed = first * weight + forward * (1.0 - weight)
  let wrapped = mixed % 360.0
  if wrapped < 0.0 {
    wrapped + 360.0
  } else {
    wrapped
  }
}

///|
/// One component of the mix: premultiplied where the space allows it, so that a
/// nearly transparent colour does not drag the result towards its own hue.
fn mix_component(
  first : Double,
  second : Double,
  weight : Double,
  first_alpha : Double,
  second_alpha : Double,
  premultiply : Bool,
) -> Double {
  if premultiply {
    first * first_alpha * weight + second * second_alpha * (1.0 - weight)
  } else {
    first * weight + second * (1.0 - weight)
  }
}

///|
/// A mix of two colours, as `color-mix()` defines one.
///
/// `weight` is how much of this colour the result has: 0.3 means three parts of
/// this and seven of `other`, which is what `color-mix(in oklab, this 30%,
/// other)` writes. It defaults to half.
///
/// The result is in the interpolation space, not in the space either colour
/// came from: mixing in Oklab and then converting back to sRGB would lose the
/// out-of-gamut values the mix produced, and `to_gamut` is the caller's decision
/// to make.
///
/// A missing component — `none` — takes the other colour's value before the
/// mix, so that `oklch(none 0.1 200)` mixed with a colour that has a lightness
/// interpolates that lightness rather than treating the gap as zero.
///
/// `currentcolor` is not mixed: it stands for a colour nobody has resolved yet,
/// and the result would be a colour that pretends to know.
pub fn Color::mix(
  self : Color,
  other : Color,
  space? : Space,
  hue? : Hue,
  weight? : Double,
) -> Color {
  let target = space.unwrap_or(Oklab)
  let direction = hue.unwrap_or(Shorter)
  let weight = clamp01(weight.unwrap_or(0.5))
  if self.is_current() || other.is_current() {
    return Color::current()
  }
  let first = self.to_space(target)
  let second = other.to_space(target)
  // A component missing on one side takes the other side's value; missing on
  // both, it stays missing.
  let fill = fn(a : Double, b : Double) { if a.is_nan() { b } else { a } }
  let (first_x, first_y, first_z) = (
    fill(first.x, second.x),
    fill(first.y, second.y),
    fill(first.z, second.z),
  )
  let (second_x, second_y, second_z) = (
    fill(second.x, first.x),
    fill(second.y, first.y),
    fill(second.z, first.z),
  )
  let first_alpha = fill(first.alpha, second.alpha)
  let second_alpha = fill(second.alpha, first.alpha)
  let alpha = first_alpha * weight + second_alpha * (1.0 - weight)
  // Premultiplication skips the hue, which is an angle rather than a quantity:
  // scaling it by opacity would move the colour around the circle.
  let hue_index = target.hue_index()
  let premultiplied = fn(index : Int32) {
    match hue_index {
      Some(hue_index) => hue_index != index
      None => true
    }
  }
  let blend = fn(first : Double, second : Double, index : Int32) {
    mix_component(
      first,
      second,
      weight,
      first_alpha,
      second_alpha,
      premultiplied(index),
    )
  }
  let mut x = blend(first_x, second_x, 0)
  let mut y = blend(first_y, second_y, 1)
  let mut z = blend(first_z, second_z, 2)
  match hue_index {
    Some(index) => {
      // The hue sits in a different component in each family, so it is taken by
      // index rather than assumed to be the last one.
      let mixed_hue = interpolate_hue(
        component_of((first_x, first_y, first_z), index),
        component_of((second_x, second_y, second_z), index),
        weight,
        direction,
      )
      if index == 0 {
        x = mixed_hue
      } else {
        z = mixed_hue
      }
    }
    None => ()
  }
  // Undo the premultiplication. A result with no alpha left has no colour to
  // recover, and dividing by zero would turn it into infinities.
  if alpha != 0.0 && !alpha.is_nan() {
    if premultiplied(0) {
      x = x / alpha
    }
    if premultiplied(1) {
      y = y / alpha
    }
    if premultiplied(2) {
      z = z / alpha
    }
  }
  Color::of(target, x, y, z, alpha~)
}

///|
/// Where a space keeps its lightness: first in the Lab family, last in the HSL
/// family, which is why the index has to be asked for rather than assumed.
fn Space::lightness_index(self : Space) -> Int32? {
  match self {
    Lab | Lch | Oklab | Oklch => Some(0)
    Hsl | Hsv | Hwb => Some(2)
    _ => None
  }
}

///|
/// The space this colour's lightness belongs to, which is its own if it has one
/// and OkLCh otherwise.
fn Color::lightness_space(self : Color) -> Space {
  match self.space {
    Hsl | Hsv | Hwb | Lab | Lch | Oklab | Oklch => self.space
    _ => Oklch
  }
}

///|
/// The space this colour's saturation belongs to: HSL and HSV saturate, and the
/// polar Lab spaces have chroma, which is the same idea under another name.
fn Color::saturation_space(self : Color) -> Space {
  match self.space {
    Hsl | Hsv | Lch | Oklch => self.space
    _ => Oklch
  }
}

///|
fn component_at(color : Color, index : Int32) -> Double {
  match index {
    0 => color.x
    1 => color.y
    _ => color.z
  }
}

///|
fn component_set(color : Color, index : Int32, value : Double) -> Color {
  match index {
    0 => color.with_components(value, color.y, color.z)
    1 => color.with_components(color.x, value, color.z)
    _ => color.with_components(color.x, color.y, value)
  }
}

///|
/// Move the colour towards white by `amount`, a fraction of its space's
/// lightness scale: 0.1 in HSL is a tenth of the way to white, 10 in Lab is ten
/// units of lightness.
///
/// Hue and chroma are kept, so this is not the same as mixing with white: a mix
/// with white washes the chroma out as well.
pub fn Color::lighten(self : Color, amount : Double) -> Color {
  let space = self.lightness_space()
  let color = self.to_space(space)
  let range = space.lightness_range()
  let index = match space.lightness_index() {
    Some(index) => index
    None => 0
  }
  let lightness = clamp01(
    (or_zero(component_at(color, index)) + amount * range) / range,
  )
  component_set(color, index, lightness * range)
}

///|
/// Move the colour towards black by `amount`, a fraction of its space's
/// lightness scale.
pub fn Color::darken(self : Color, amount : Double) -> Color {
  self.lighten(-amount)
}

///|
/// Add chroma, by `amount` of the space's chroma scale: 0.1 is a tenth of the
/// chroma a colour can have before it leaves what people can see.
///
/// A colour already at the edge of its gamut gains nothing visible from this;
/// `to_gamut` afterwards is what says so.
pub fn Color::saturate(self : Color, amount : Double) -> Color {
  let space = self.saturation_space()
  let color = self.to_space(space)
  let range = space.chroma_range()
  let chroma = or_zero(color.y) + amount * range
  let chroma = if chroma < 0.0 { 0.0 } else { chroma }
  // HSL and HSV saturate to a point and stop; chroma does not have an end.
  let chroma = match space {
    Hsl | Hsv => clamp01(chroma)
    _ => chroma
  }
  component_set(color, 1, chroma)
}

///|
/// Take chroma away, by `amount` of the space's chroma scale. At 1 the colour is
/// grey.
pub fn Color::desaturate(self : Color, amount : Double) -> Color {
  self.saturate(-amount)
}

///|
/// The same colour with less alpha, by `amount` of what it has: 0.25 leaves
/// three quarters.
pub fn Color::fade(self : Color, amount : Double) -> Color {
  let alpha = if self.alpha.is_nan() { 1.0 } else { self.alpha }
  self.with_alpha(clamp01(alpha * (1.0 - amount)))
}