/**
 * @adia-ai/web-components/color — color-space conversion + gamut utilities
 * shared between primitives (color-picker, swatch, contrast-badge) and
 * available to consumers as a standalone subpath.
 *
 * §301 (v0.5.12, FEEDBACK-29 re-bucket from v0.6.0). Pure functions; no
 * DOM/runtime side-effects. Pre-§301 these helpers lived inline at
 * `components/color-picker/color-picker.class.js:34-121`. Extracted into a shared
 * subpath so consumers (Tokens Studio, custom palette tools) don't
 * re-implement OKLCH math.
 *
 * Math sources:
 * - OKLab ↔ linear-sRGB matrix: Björn Ottosson's canonical coefficients.
 * - Gamut-mapping: chroma-clamping bisection (8 iterations; matches
 *   the color-picker's on-track clamp behavior).
 *
 * @see https://bottosson.github.io/posts/oklab/
 * @see https://www.w3.org/TR/css-color-4/#color-conversion-code
 */

const MAX_CHROMA = 0.4;
const GAMUT_EPSILON = 0.001;

// ── OKLCH ↔ OKLab ─────────────────────────────────────────────────────────

/** Convert OKLCH (cylindrical) to OKLab (Cartesian). H is in degrees. */
export function oklchToOklab(L, C, H) {
  const hRad = H * Math.PI / 180;
  return { L, a: C * Math.cos(hRad), b: C * Math.sin(hRad) };
}

/** Convert OKLab (Cartesian) to OKLCH (cylindrical). H returned in degrees [0, 360). */
export function oklabToOklch(L, a, b) {
  const C = Math.sqrt(a * a + b * b);
  let H = Math.atan2(b, a) * 180 / Math.PI;
  if (H < 0) H += 360;
  return { L, C, H };
}

// ── OKLab ↔ sRGB (via linear sRGB) ────────────────────────────────────────

/**
 * OKLab → linear sRGB. Returns `[r, g, b]` in `[0, 1]` (unclamped).
 * Use `linearToSrgb()` channel-by-channel to apply the sRGB transfer
 * function; use `isInGamut()` to check if the result lies inside sRGB.
 */
export function oklabToLinearSrgb(L, a, b) {
  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
  const l = l_ * l_ * l_;
  const m = m_ * m_ * m_;
  const s = s_ * s_ * s_;
  return [
    +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  ];
}

/** sRGB transfer function: linear → gamma-encoded sRGB. */
export function linearToSrgb(c) {
  return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
}

/** Inverse sRGB transfer function: gamma-encoded sRGB → linear. */
export function srgbToLinear(c) {
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

/**
 * OKLCH → sRGB clamped to `[0, 1]`. Returns `[r, g, b]`. Out-of-gamut
 * colors are channel-clamped (not gamut-mapped); use `gamutMapChroma()`
 * first if you need the closest in-gamut color.
 */
export function oklchToRgb(L, C, H) {
  const { a, b } = oklchToOklab(L, C, H);
  const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
  return [
    Math.max(0, Math.min(1, linearToSrgb(lr))),
    Math.max(0, Math.min(1, linearToSrgb(lg))),
    Math.max(0, Math.min(1, linearToSrgb(lb))),
  ];
}

// ── Hex ↔ OKLCH ───────────────────────────────────────────────────────────

/**
 * Convert sRGB `[r, g, b]` (each in `[0, 1]`) to hex string `#rrggbb`.
 * Channel values are rounded to the nearest byte; out-of-range clamped.
 */
export function rgbToHex(r, g, b) {
  const h = (c) => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0');
  return `#${h(r)}${h(g)}${h(b)}`;
}

/** OKLCH → hex string `#rrggbb`. Channel-clamped to sRGB gamut. */
export function oklchToHex(L, C, H) {
  const [r, g, b] = oklchToRgb(L, C, H);
  return rgbToHex(r, g, b);
}

/**
 * Hex string `#rgb` or `#rrggbb` → OKLCH `{ L, C, H }`. H in degrees [0, 360).
 * Accepts both 3- and 6-digit shorthand; tolerant of leading `#`.
 */
export function hexToOklch(hex) {
  hex = hex.replace('#', '');
  if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
  const r = srgbToLinear(parseInt(hex.slice(0, 2), 16) / 255);
  const g = srgbToLinear(parseInt(hex.slice(2, 4), 16) / 255);
  const b = srgbToLinear(parseInt(hex.slice(4, 6), 16) / 255);
  const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
  const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
  const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
  const bv = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
  return oklabToOklch(L, a, bv);
}

// ── Gamut checking + mapping ──────────────────────────────────────────────

/** Returns true if all 3 sRGB channels lie in `[0, 1]` (tolerance `0.001`). */
export function isInGamut(r, g, b) {
  return r >= -GAMUT_EPSILON && r <= 1 + GAMUT_EPSILON
    && g >= -GAMUT_EPSILON && g <= 1 + GAMUT_EPSILON
    && b >= -GAMUT_EPSILON && b <= 1 + GAMUT_EPSILON;
}

/** Returns true if the OKLCH triple is inside sRGB gamut. */
export function isOklchInGamut(L, C, H) {
  const { a, b } = oklchToOklab(L, C, H);
  const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
  return isInGamut(linearToSrgb(lr), linearToSrgb(lg), linearToSrgb(lb));
}

/**
 * Bisect chroma to find the largest value that keeps `(L, _, H)` in sRGB
 * gamut. Used when the consumer requests a chroma outside the displayable
 * range (e.g., dragging the picker's chroma slider past the gamut boundary).
 *
 * 8 bisection iterations — matches the color-picker's on-track clamp
 * behavior + completes in <1ms.
 */
export function gamutMapChroma(L, C, H) {
  if (isOklchInGamut(L, C, H)) return C;
  let lo = 0, hi = C;
  for (let i = 0; i < 8; i++) {
    const mid = (lo + hi) / 2;
    if (isOklchInGamut(L, mid, H)) lo = mid;
    else hi = mid;
  }
  return lo;
}

// ── Display-P3 gamut ──────────────────────────────────────────────────────
//
// v0.5.13 §-TBD (FB-31). The v0.6.0 §301 plan-doc spec committed
// `inP3Gamut(o)` + `gamutMapOklch(o, 'p3')`; the v0.5.12 §301 ship dropped
// both (sRGB-only surface). FB-31 closes the gap with explicit P3 helpers
// matching the existing scalar-channels signature shape.
//
// sRGB-linear → Display-P3-linear matrix sourced from CSS Color 4 § 12.3
// (assumes both spaces share the D65 white point, which is true post-2018
// browser implementations; no Bradford chromatic adaptation needed).

function linearSrgbToLinearP3(r, g, b) {
  return [
    0.8224621 * r + 0.1775380 * g + 0.0000000 * b,
    0.0331941 * r + 0.9668058 * g + 0.0000001 * b,
    0.0170827 * r + 0.0723974 * g + 0.9105199 * b,
  ];
}

/** Returns true if the OKLCH triple is inside Display-P3 gamut (tolerance `0.001`). */
export function inP3Gamut(L, C, H) {
  const { a, b } = oklchToOklab(L, C, H);
  const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
  const [p3r, p3g, p3b] = linearSrgbToLinearP3(lr, lg, lb);
  return isInGamut(linearToSrgb(p3r), linearToSrgb(p3g), linearToSrgb(p3b));
}

/**
 * Bisect chroma to find the largest value that keeps `(L, _, H)` in
 * Display-P3 gamut. 8 bisection iterations — same shape as the sRGB
 * `gamutMapChroma`. Used by P3-aware design tooling (Tokens Studio,
 * theme designers targeting P3-display laptops + iPads).
 */
export function gamutMapChromaP3(L, C, H) {
  if (inP3Gamut(L, C, H)) return C;
  let lo = 0, hi = C;
  for (let i = 0; i < 8; i++) {
    const mid = (lo + hi) / 2;
    if (inP3Gamut(L, mid, H)) lo = mid;
    else hi = mid;
  }
  return lo;
}

// ── APCA (advanced perceptual contrast algorithm) ─────────────────────────
//
// Source of truth: Andrew Somers' SACAM specification + apca-w3@0.1.9 (the
// W3C-pinned reference impl). The soft-clamp threshold is `0.022` for both
// fg + bg luminance values (per `SAPC_BLACK_THRESHOLD` in apca-w3 source).
// The 1.414 exponent is the APCA "BLACK_CLAMP" rescale constant.
//
// v0.5.13 §-TBD (FB-35): corrected the soft-clamp constant. v0.5.12 ship
// used 0.06 (declared as APCA_LO_CLAMP) by mistake, producing Lc 99.49 for
// black-on-white where canonical is 106.04 (-6.5pt bias for any pair
// involving near-black). Constants APCA_LO_FG / APCA_LO_BG were declared
// correctly but never read; the 0.06 constant was a stray from an earlier
// draft. apcaSrgbY() unchanged.

const APCA_LO_BG = 0.022;
const APCA_LO_FG = 0.022;
const APCA_BLACK_CLAMP = 1.414;

function apcaSrgbY(rNorm, gNorm, bNorm) {
  // sRGB → relative luminance via APCA Y (different coefficients than WCAG).
  // Per the SACAM specification (Andrew Somers, github.com/Myndex/apca-w3).
  const r = Math.pow(rNorm, 2.4);
  const g = Math.pow(gNorm, 2.4);
  const b = Math.pow(bNorm, 2.4);
  return 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
}

/**
 * Compute APCA contrast (Lc) between a foreground hex color and a
 * background hex color. Returns the signed Lc value:
 *   - Positive = dark text on light bg ("normal polarity")
 *   - Negative = light text on dark bg ("reverse polarity")
 *
 * Typical thresholds (from APCA's "Bronze Simple Mode"):
 *   - Body text:        |Lc| ≥ 75 (4.5:1 WCAG analog)
 *   - Large/bold text:  |Lc| ≥ 60 (3:1 WCAG analog)
 *   - Incidental:       |Lc| ≥ 45 (chrome, decorative)
 *
 * @see https://www.myndex.com/APCA/
 */
export function apcaContrast(fgHex, bgHex) {
  const parse = (hex) => {
    const h = hex.replace('#', '');
    const norm = (s) => parseInt(s, 16) / 255;
    return {
      r: norm(h.slice(0, 2)),
      g: norm(h.slice(2, 4)),
      b: norm(h.slice(4, 6)),
    };
  };
  const fg = parse(fgHex);
  const bg = parse(bgHex);
  let Yfg = apcaSrgbY(fg.r, fg.g, fg.b);
  let Ybg = apcaSrgbY(bg.r, bg.g, bg.b);

  // Soft clamp near black per APCA spec — threshold 0.022 per SACAM /
  // apca-w3@0.1.9 (`SAPC_BLACK_THRESHOLD`).
  if (Yfg < APCA_LO_FG) Yfg += Math.pow(APCA_LO_FG - Yfg, APCA_BLACK_CLAMP);
  if (Ybg < APCA_LO_BG) Ybg += Math.pow(APCA_LO_BG - Ybg, APCA_BLACK_CLAMP);

  let Lc;
  if (Ybg > Yfg) {
    // Dark text on light bg ("normal polarity") → positive Lc
    Lc = (Math.pow(Ybg, 0.56) - Math.pow(Yfg, 0.57)) * 1.14 * 100;
    if (Lc < 7.5) Lc = 0; // below perception floor
    else Lc = Lc - 0.027 * 100;
  } else {
    // Light text on dark bg ("reverse polarity") → negative Lc
    Lc = (Math.pow(Ybg, 0.65) - Math.pow(Yfg, 0.62)) * 1.14 * 100;
    if (Lc > -7.5) Lc = 0;
    else Lc = Lc + 0.027 * 100;
  }
  return Lc;
}

/**
 * Convenience: choose a foreground (light or dark) that maximizes APCA
 * contrast against the given background hex. Returns either '#000000' or
 * '#ffffff'. Used by `<swatch-ui auto-contrast>`'s OKLab-L probe.
 */
export function pickContrastingFg(bgHex) {
  const onDark = apcaContrast('#ffffff', bgHex);
  const onLight = apcaContrast('#000000', bgHex);
  return Math.abs(onDark) >= Math.abs(onLight) ? '#ffffff' : '#000000';
}

// ── LCH-space distance (perceptual distance) ──────────────────────────────

/**
 * Compute perceptual distance between two OKLCH triples.
 * Returns Euclidean distance in OKLab space — perceptually uniform.
 * Useful for "closest swatch in a palette" lookups.
 */
export function oklchDistance(a, b) {
  const aLab = oklchToOklab(a.L, a.C, a.H);
  const bLab = oklchToOklab(b.L, b.C, b.H);
  const dL = aLab.L - bLab.L;
  const dA = aLab.a - bLab.a;
  const dB = aLab.b - bLab.b;
  return Math.sqrt(dL * dL + dA * dA + dB * dB);
}

// ── Re-export constants ──────────────────────────────────────────────────

export { MAX_CHROMA, GAMUT_EPSILON };
