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

// How readable one colour is on another.
//
// Two answers, because the field has two. WCAG 2's contrast ratio is the one
// that regulations and audits ask for: a ratio from 1 to 21, symmetric, and
// blunt — it treats a pair of dark colours and a pair of light ones the same way
// round, which is not how eyes work. APCA is its proposed replacement: a
// signed lightness contrast from about −108 to 108, directional, and built from
// a model of how the visual system responds to polarity.
//
// Both start from luminance, and both are defined on sRGB: a colour in a wider
// gamut is converted, and one outside 0–1 is clamped, because the question is
// what a screen shows.

///|
/// Relative luminance per WCAG 2: sRGB made linear, weighted by the eye's
/// sensitivity to each primary, with the components clamped to what a screen can
/// show.
pub fn Color::luminance(self : Color) -> Double {
  let (r, g, b) = self.to_srgb().components()
  let linear = fn(value : Double) -> Double {
    let value = if value.is_nan() {
      0.0
    } else if value < 0.0 {
      0.0
    } else if value > 1.0 {
      1.0
    } else {
      value
    }
    // WCAG writes the break at 0.03928 where the sRGB specification writes
    // 0.04045; the two agree to seven places and the definition is WCAG's.
    if value <= 0.03928 {
      value / 12.92
    } else {
      @math.pow((value + 0.055) / 1.055, 2.4)
    }
  }
  0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}

///|
/// The WCAG 2 contrast ratio between two colours: 1 for the same colour, 21 for
/// black on white.
///
/// The order does not matter — this is the measure that is symmetric, and that
/// is one of the reasons APCA exists.
pub fn Color::contrast(self : Color, other : Color) -> Double {
  let first = self.luminance()
  let second = other.luminance()
  let lighter = if first > second { first } else { second }
  let darker = if first > second { second } else { first }
  (lighter + 0.05) / (darker + 0.05)
}

///|
/// The APCA lightness contrast `Lc` of this colour as text on `background`:
/// positive for dark text on a light background, negative the other way round,
/// and zero when the pair is too close to read at all.
///
/// The constants are APCA 0.98G-4g, the version the SAPC project publishes for
/// web use. A sign-flipped result is not a mistake: it says which way round the
/// pair is, and the thresholds for readable text are different in each
/// direction, so a caller that wants "is this readable" has to ask about a
/// specific way round.
pub fn Color::apca(self : Color, background : Color) -> Double {
  let text = apca_luminance(self)
  let backdrop = apca_luminance(background)
  // A soft clamp near black: what a screen actually emits at the bottom of its
  // range is not linear in the signal, and the model accounts for it.
  let soft = fn(value : Double) {
    if value > 0.022 {
      value
    } else {
      value + @math.pow(0.022 - value, 1.414)
    }
  }
  let text = soft(text)
  let backdrop = soft(backdrop)
  if (backdrop - text).abs() < 0.0005 {
    return 0.0
  }
  if backdrop > text {
    let raw = (@math.pow(backdrop, 0.56) - @math.pow(text, 0.57)) * 1.14
    if raw < 0.1 {
      0.0
    } else {
      (raw - 0.027) * 100.0
    }
  } else {
    let raw = (@math.pow(backdrop, 0.65) - @math.pow(text, 0.62)) * 1.14
    if raw > -0.1 {
      0.0
    } else {
      (raw + 0.027) * 100.0
    }
  }
}

///|
/// APCA's own luminance: a 2.4 power of each sRGB component, without the linear
/// segment WCAG keeps near black. For values that a screen can show the two
/// differ by very little; near black they differ by design.
fn apca_luminance(color : Color) -> Double {
  let (r, g, b) = color.to_srgb().components()
  let channel = fn(value : Double) {
    let value = if value.is_nan() {
      0.0
    } else if value < 0.0 {
      0.0
    } else if value > 1.0 {
      1.0
    } else {
      value
    }
    @math.pow(value, 2.4)
  }
  0.2126729 * channel(r) + 0.7151522 * channel(g) + 0.072175 * channel(b)
}