/**
 * Color utilities for the editor.
 *
 * Currently exports:
 *   • `darkenHexColor` — used by the Text tab to derive a default
 *     outline color from the user's chosen fill color.
 *   • `relativeLuminance` / `getContrastRatio` — WCAG-style contrast
 *     math, used by the Text tab's preview swatch to pick a readable
 *     background based on the current fill (light pink wash by
 *     default; darker pink fallback when the fill would be invisible
 *     against the wash).
 *   • `LOW_CONTRAST_THRESHOLD` / `isLowContrast` — convenience
 *     predicate + threshold for "these two colors are too similar to
 *     be safely distinguishable." No production callers as of writing;
 *     kept as a public API surface for future warning paths so the
 *     threshold lives in one canonical place rather than being
 *     redefined per call site. The Text tab uses a locally-named
 *     `LOW_PREVIEW_CONTRAST_THRESHOLD` (same 2.0 value) for its own
 *     preview-surface check — leaving that local because the preview
 *     threshold and a global "warn the user" threshold may diverge.
 *
 * We keep these in their own module rather than inlining them because
 * the same transforms may end up wanted elsewhere (hover states,
 * accessibility checks on imported palettes, gradient stops, etc.),
 * and a one-line import keeps downstream call sites tidy.
 *
 * History
 * ───────
 * Pre-May 23 2026 the contrast logic was split across two files:
 * `colorUtils.js` (this file, with darkenHexColor + getContrastRatio +
 * relativeLuminance) and `colorContrast.js` (a separate module with a
 * redundant `contrastRatio` plus `isLowContrast` and the threshold
 * constant). The two contrast implementations differed only in their
 * malformed-input fallback — colorUtils returned NaN ("unknown, caller
 * decides"), colorContrast returned 1 ("treat as identical"). The
 * split was speculative API; the only production caller (TextTab) used
 * colorUtils' NaN-aware path. Merged into this file with NaN as the
 * canonical fallback (see `isLowContrast` below).
 */

/**
 * Darken a hex color by scaling each RGB channel toward zero.
 *
 * Why RGB scaling and not HSL lightness reduction:
 *   • Visually equivalent for the small (≤ 30%) darkening this app
 *     uses — the perceptual difference between HSL-based and
 *     RGB-scaled darkening is negligible below ~40%.
 *   • Cheaper: no RGB → HSL → RGB round-trip, no math.atan2 calls.
 *   • Handles edge cases naturally: pure black stays pure black
 *     (0 × anything = 0), pure white darkens uniformly to grey,
 *     coloured fills keep their hue cleanly because every channel
 *     scales by the same factor.
 *
 * Input must be a 3- or 6-digit hex string with or without the
 * leading `#`. Anything else is returned unchanged so a malformed
 * color in state can't crash a render — the caller's UI just
 * shows the original color and the user can pick a new one.
 *
 * `amount` is a fraction in [0, 1]:
 *   amount = 0    → returns the input unchanged
 *   amount = 0.2  → 20% darker (the Text tab's outline default)
 *   amount = 1    → returns #000000
 *
 * Values outside [0, 1] are clamped so a typo in a caller can't
 * produce out-of-gamut values like negative channels.
 */
export function darkenHexColor(hex, amount = 0.2) {
  if (typeof hex !== 'string') return hex;
  let h = hex.trim();
  if (h.startsWith('#')) h = h.slice(1);
  // Expand short-form (#abc) to long-form (#aabbcc) so the parse below
  // is uniform. Browsers accept both; canvas APIs sometimes don't.
  if (h.length === 3) h = h.split('').map((c) => c + c).join('');
  if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return hex;

  const clamped = Math.max(0, Math.min(1, amount));
  const factor = 1 - clamped;

  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);

  const nr = Math.round(r * factor);
  const ng = Math.round(g * factor);
  const nb = Math.round(b * factor);

  const toHex = (v) => v.toString(16).padStart(2, '0');
  return `#${toHex(nr)}${toHex(ng)}${toHex(nb)}`;
}

/**
 * Parse a hex color string into a canonical 6-char hex (no #).
 * Returns null on malformed input. Shared by relativeLuminance and
 * any future callers that need normalized hex.
 */
function normalizeHex(hex) {
  if (typeof hex !== 'string') return null;
  let h = hex.trim();
  if (h.startsWith('#')) h = h.slice(1);
  if (h.length === 3) h = h.split('').map((c) => c + c).join('');
  if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
  return h;
}

/**
 * Relative luminance of an sRGB color per the WCAG 2.x formula.
 *
 * Returns a value in [0, 1]: 0 = black, 1 = white, mid-grey ≈ 0.18.
 * The non-linear `channel` transform is the sRGB gamma curve — it
 * converts each 0–255 channel into linear-light space before the
 * weighted sum, which is what gives WCAG contrast ratios their
 * perceptual accuracy. Skipping the gamma step (i.e. using raw
 * RGB averages) under-estimates contrast on light backgrounds and
 * over-estimates it on dark ones.
 *
 * Returns NaN for malformed input. Callers should treat NaN as
 * "unknown" and either bail or fall back to a safe default rather
 * than passing the value into further math.
 */
export function relativeLuminance(hex) {
  const h = normalizeHex(hex);
  if (!h) return NaN;

  // sRGB → linear-light gamma curve (the WCAG canonical form).
  const channel = (v) => {
    const s = v / 255;
    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  };

  const r = channel(parseInt(h.slice(0, 2), 16));
  const g = channel(parseInt(h.slice(2, 4), 16));
  const b = channel(parseInt(h.slice(4, 6), 16));

  // Weighting coefficients are the standard sRGB → luminance matrix
  // row — green dominates because the human eye is most sensitive to
  // mid-green wavelengths, blue contributes the least.
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
 * WCAG contrast ratio between two colors.
 *
 * Returns a value in [1, 21]:
 *   1   = identical luminance (invisible)
 *   21  = pure black on pure white (theoretical max)
 *
 * WCAG AA passing thresholds:
 *   4.5:1 for normal text
 *   3.0:1 for large text (18pt+ regular, or 14pt+ bold)
 *
 * Returns NaN if either input is malformed (so the caller can pick
 * a safe fallback rather than producing garbage UI). Order of
 * arguments doesn't matter — the formula is symmetric, with the
 * lighter colour always going on top.
 */
export function getContrastRatio(hex1, hex2) {
  const l1 = relativeLuminance(hex1);
  const l2 = relativeLuminance(hex2);
  if (Number.isNaN(l1) || Number.isNaN(l2)) return NaN;
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

/**
 * Threshold below which two colors are considered "too similar to
 * safely distinguish." WCAG AA proper requires ≥4.5:1 for body text
 * and ≥3.0:1 for large text; 2.0 is a relaxed threshold appropriate
 * for decorative print where the user is making an aesthetic choice
 * rather than a readability-critical one. "You can still see it but
 * it's getting hard" is the right level for a warning surface; a
 * proper AA threshold would warn so frequently on stylish colour
 * combinations that users would tune it out.
 *
 * Lives here rather than in a caller-local constant so any future
 * call site that wants "the project's low-contrast threshold" has
 * one canonical reference. Call sites with a more specific need
 * (e.g. TextTab's preview-surface check) can still define their
 * own.
 */
export const LOW_CONTRAST_THRESHOLD = 2.0;

/**
 * Predicate: do these two colors fall below the low-contrast threshold?
 *
 * NaN handling
 * ────────────
 * Returns FALSE when either color is malformed (getContrastRatio
 * returns NaN). "Unknown contrast" reads as "don't warn" rather than
 * "warn just in case" — a malformed colour in state is more likely
 * to be a stale value the user is about to overwrite than a real
 * low-contrast pair, and warning on it would produce a false positive
 * the user can't act on (they didn't pick a colour; they're picking
 * one right now). The TextTab caller pattern
 * (`!Number.isNaN(contrast) && contrast < threshold`) had the same
 * semantics inline; consolidating it here makes the choice explicit.
 *
 * Argument order is `(fg, bg)` by convention — foreground first —
 * but the underlying WCAG math is symmetric, so callers passing them
 * in the other order get the same answer.
 */
export function isLowContrast(fg, bg, threshold = LOW_CONTRAST_THRESHOLD) {
  const ratio = getContrastRatio(fg, bg);
  if (Number.isNaN(ratio)) return false;
  return ratio < threshold;
}
