// Tests for colorUtils.js — darkenHexColor, relativeLuminance, getContrastRatio.
//
// Why this file matters for the v1 cleanup
// ────────────────────────────────────────
// `colorUtils.js` and `colorContrast.js` (tested separately in
// colorContrast.test.js) BOTH implement the WCAG relative-luminance
// and contrast-ratio formulas, with subtly different APIs:
//
//                    | colorUtils                  | colorContrast
//   ─────────────────┼─────────────────────────────┼──────────────────────────
//   relativeLuminance| takes hex string            | takes parsed {r,g,b}
//                    | returns NaN on bad input    | (internal helper only)
//   contrast getter  | getContrastRatio (NaN)      | contrastRatio (returns 1)
//   threshold helper | (none)                      | isLowContrast(default 2)
//   color transforms | darkenHexColor              | (none)
//
// Same underlying WCAG math, two slightly different surfaces. The
// upcoming cleanup is to consolidate them into a single module. These
// tests (plus the colorContrast.test.js sibling) pin BOTH current
// behaviors so the consolidation can be done as a green refactor:
// either adopt one module's API and update the other's callers, or
// introduce a single new API and migrate both — in either case the
// test suite tells us when behavior matches.
//
// Specifically the "returns NaN on bad input" vs "returns 1 on bad
// input" divergence: callers of colorUtils.getContrastRatio currently
// expect to test for NaN; callers of colorContrast.contrastRatio
// currently expect 1 as a safe-fallback value. Whichever direction
// we pick during cleanup, the side that changes will need its callers
// audited. The malformed-input tests below flag this divergence.
//
// UPDATE (May 23 2026): the consolidation landed. colorContrast.js
// and colorContrast.test.js are deleted; everything lives here and
// NaN-on-bad-input is the canonical fallback throughout. The header
// above is preserved so commits referencing "the divergence" still
// make sense; the post-merge behaviour is documented in
// colorUtils.js's module docblock.

import {
  darkenHexColor,
  relativeLuminance,
  getContrastRatio,
  LOW_CONTRAST_THRESHOLD,
  isLowContrast,
} from './colorUtils';

// ────────────────────────────────────────────────────────────────────
// darkenHexColor — RGB-scaling darken.
// ────────────────────────────────────────────────────────────────────

describe('darkenHexColor', () => {
  describe('boundary amounts', () => {
    it('amount=0 returns the input unchanged', () => {
      expect(darkenHexColor('#ff0000', 0)).toBe('#ff0000');
      expect(darkenHexColor('#abc123', 0)).toBe('#abc123');
    });

    it('amount=1 returns #000000 (everything scaled to zero)', () => {
      expect(darkenHexColor('#ff0000', 1)).toBe('#000000');
      expect(darkenHexColor('#ffffff', 1)).toBe('#000000');
      expect(darkenHexColor('#abc123', 1)).toBe('#000000');
    });

    it('default amount is 0.2 (matches the Text tab outline darken)', () => {
      // Caller-side: `darkenHexColor(fill)` produces the default outline
      // colour for the Text tab. The default amount must stay 0.2 or
      // the visual relationship between fill and outline changes.
      expect(darkenHexColor('#ffffff')).toBe('#cccccc');
      // 0xff * 0.8 = 204 = 0xcc.
    });

    it('clamps amounts above 1 to 1', () => {
      expect(darkenHexColor('#ff0000', 5)).toBe('#000000');
      expect(darkenHexColor('#ff0000', 1.5)).toBe('#000000');
    });

    it('clamps negative amounts to 0', () => {
      // A typo in a caller passing a negative amount shouldn't produce
      // out-of-gamut channels (e.g. `-0.2` would lighten if we didn't
      // clamp). Clamping to 0 preserves input.
      expect(darkenHexColor('#ff0000', -0.2)).toBe('#ff0000');
      expect(darkenHexColor('#ff0000', -1)).toBe('#ff0000');
    });
  });

  describe('channel math', () => {
    it('20% darker on pure red is #cc0000', () => {
      // 0xff (255) * 0.8 = 204 = 0xcc.
      expect(darkenHexColor('#ff0000', 0.2)).toBe('#cc0000');
    });

    it('20% darker on pure white is #cccccc', () => {
      expect(darkenHexColor('#ffffff', 0.2)).toBe('#cccccc');
    });

    it('pure black stays pure black at any amount', () => {
      // 0 * anything = 0. This is a useful invariant — callers that
      // chain darken operations shouldn't ever produce a non-zero
      // channel from a zero source.
      expect(darkenHexColor('#000000', 0.5)).toBe('#000000');
      expect(darkenHexColor('#000000', 1)).toBe('#000000');
    });

    it('rounds channel values rather than truncating', () => {
      // Math.round, not Math.floor — 0xab (171) * 0.8 = 136.8 should
      // round to 137 (0x89), not floor to 136 (0x88).
      expect(darkenHexColor('#ababab', 0.2)).toBe('#898989');
    });

    it('treats channels independently (preserves hue at small darken amounts)', () => {
      // Every channel scales by the same factor, so the ratios between
      // them are preserved. A 50%-red, 50%-green-blue color stays
      // 50%-red, 50%-green-blue after darkening.
      const result = darkenHexColor('#80c0ff', 0.5);
      // 0x80 = 128 → 64 = 0x40
      // 0xc0 = 192 → 96 = 0x60
      // 0xff = 255 → 128 (round of 127.5) = 0x80
      expect(result).toBe('#406080');
    });
  });

  describe('input formats', () => {
    it('accepts hex with leading #', () => {
      expect(darkenHexColor('#ff0000', 0.2)).toBe('#cc0000');
    });

    it('accepts hex without leading # and adds # to output', () => {
      // The output always has a leading # for consistency, even if
      // the input didn't. Callers serializing back to CSS or hex
      // pickers should not have to test which case they got.
      expect(darkenHexColor('ff0000', 0.2)).toBe('#cc0000');
    });

    it('expands 3-char hex shorthand to 6-char before darkening', () => {
      // #abc → expand to #aabbcc, then darken.
      // 0xaa=170 * 0.8 = 136 = 0x88
      // 0xbb=187 * 0.8 = 149.6 → 150 = 0x96
      // 0xcc=204 * 0.8 = 163.2 → 163 = 0xa3
      expect(darkenHexColor('#abc', 0.2)).toBe('#8896a3');
    });

    it('accepts uppercase hex and emits lowercase', () => {
      // The browser and Konva both accept either case, but a single
      // canonical case in storage makes equality checks on `fill ===
      // stroke` reliable (the sharedStyle comparison in TextTab.jsx
      // depends on this).
      expect(darkenHexColor('#FF0000', 0.2)).toBe('#cc0000');
    });

    it('trims whitespace before parsing', () => {
      expect(darkenHexColor('  #ff0000  ', 0.2)).toBe('#cc0000');
    });
  });

  describe('malformed input passthrough', () => {
    // Per docblock: malformed input is returned UNCHANGED rather than
    // throwing or returning a sentinel. The caller's UI shows the
    // original (broken) color and the user can pick a new one. This
    // is a deliberate non-failure mode — never crash a render over a
    // typo in state.

    it('non-string input is returned unchanged', () => {
      expect(darkenHexColor(null, 0.2)).toBe(null);
      expect(darkenHexColor(undefined, 0.2)).toBe(undefined);
      expect(darkenHexColor(42, 0.2)).toBe(42);
      expect(darkenHexColor({}, 0.2)).toEqual({});
    });

    it('hex with wrong length is returned unchanged', () => {
      // 4 chars, 5 chars, 7 chars — none are valid hex.
      expect(darkenHexColor('#abcd', 0.2)).toBe('#abcd');
      expect(darkenHexColor('#abcde', 0.2)).toBe('#abcde');
      expect(darkenHexColor('#abcdef0', 0.2)).toBe('#abcdef0');
    });

    it('hex containing non-hex characters is returned unchanged', () => {
      expect(darkenHexColor('#gghhii', 0.2)).toBe('#gghhii');
      expect(darkenHexColor('#zz0000', 0.2)).toBe('#zz0000');
    });

    it('empty string is returned unchanged', () => {
      // After stripping #, the empty string has length 0 — not 3, not 6.
      expect(darkenHexColor('', 0.2)).toBe('');
    });
  });
});

// ────────────────────────────────────────────────────────────────────
// relativeLuminance — WCAG canonical formula.
// ────────────────────────────────────────────────────────────────────

describe('relativeLuminance', () => {
  it('pure black is 0', () => {
    expect(relativeLuminance('#000000')).toBe(0);
  });

  it('pure white is 1', () => {
    expect(relativeLuminance('#ffffff')).toBe(1);
  });

  it('mid-grey is approximately 0.215', () => {
    // The exact WCAG-canonical mid-grey (#808080) luminance, computed
    // via the sRGB → linear-light gamma curve. Useful as a known
    // intermediate to confirm the gamma transform is wired correctly —
    // a non-gamma-corrected formula would give ~0.5 here.
    const result = relativeLuminance('#808080');
    expect(result).toBeGreaterThan(0.21);
    expect(result).toBeLessThan(0.22);
  });

  it('channel weighting: pure green > pure red > pure blue', () => {
    // The luminance weights are G=0.7152, R=0.2126, B=0.0722. The
    // ordering reflects human perception (green-dominant cones), and
    // confirms the per-channel coefficients aren't swapped — a bug
    // we'd want to catch.
    const lG = relativeLuminance('#00ff00');
    const lR = relativeLuminance('#ff0000');
    const lB = relativeLuminance('#0000ff');
    expect(lG).toBeGreaterThan(lR);
    expect(lR).toBeGreaterThan(lB);
    expect(lG).toBeCloseTo(0.7152, 4);
    expect(lR).toBeCloseTo(0.2126, 4);
    expect(lB).toBeCloseTo(0.0722, 4);
  });

  it('accepts shorthand 3-char hex (expands to 6-char)', () => {
    expect(relativeLuminance('#fff')).toBe(1);
    expect(relativeLuminance('#000')).toBe(0);
  });

  it('returns NaN for malformed input', () => {
    // colorUtils' convention: NaN means "unknown — bail or fall back".
    // Compare with colorContrast.relativeLuminance which uses parseHex
    // and returns from a higher-level helper.
    expect(relativeLuminance('not a color')).toBeNaN();
    expect(relativeLuminance('#zz0000')).toBeNaN();
    expect(relativeLuminance(null)).toBeNaN();
    expect(relativeLuminance(undefined)).toBeNaN();
    expect(relativeLuminance('')).toBeNaN();
  });
});

// ────────────────────────────────────────────────────────────────────
// getContrastRatio — WCAG contrast formula.
// ────────────────────────────────────────────────────────────────────

describe('getContrastRatio', () => {
  it('pure black on pure white is 21 (theoretical max)', () => {
    // (1 + 0.05) / (0 + 0.05) = 21
    expect(getContrastRatio('#000000', '#ffffff')).toBe(21);
  });

  it('identical colors have ratio 1', () => {
    expect(getContrastRatio('#ff0000', '#ff0000')).toBe(1);
    expect(getContrastRatio('#808080', '#808080')).toBe(1);
    expect(getContrastRatio('#000000', '#000000')).toBe(1);
  });

  it('is symmetric (order of arguments does not matter)', () => {
    // The formula picks max/min internally, so swapping args is a
    // no-op. Worth pinning because a "swap them later" optimization
    // could accidentally break symmetry.
    const a = getContrastRatio('#ff0000', '#00ff00');
    const b = getContrastRatio('#00ff00', '#ff0000');
    expect(a).toBe(b);
  });

  it('returns NaN if either input is malformed', () => {
    // This is colorUtils' convention. colorContrast.contrastRatio
    // returns 1 instead — that divergence is flagged in this file's
    // header comment.
    //
    // Be careful picking "malformed" inputs: 3-char strings like 'bad',
    // 'fed', 'cab', 'dad', etc. are VALID hex shorthand because b, a,
    // d, c, e, f are all hex digits — they parse to #bbaadd, #ffeedd,
    // etc. and produce real contrast ratios. The malformed-input
    // assertions need strings that contain non-hex characters or
    // wrong-length sequences.
    expect(getContrastRatio('#ff0000', 'not a color')).toBeNaN();
    expect(getContrastRatio('not a color', '#ff0000')).toBeNaN();
    expect(getContrastRatio('xyz', 'qrs')).toBeNaN();           // 3 chars but non-hex
    expect(getContrastRatio('zzzzzz', '#ff0000')).toBeNaN();    // 6 chars but non-hex
    expect(getContrastRatio(null, '#000000')).toBeNaN();
    expect(getContrastRatio(undefined, '#000000')).toBeNaN();
  });

  it('black on mid-grey is between WCAG AA-large (3:1) and AA-normal (4.5:1) thresholds', () => {
    // (~0.215 + 0.05) / (0 + 0.05) = ~5.3 — passes both AA thresholds.
    // Range check rather than exact value so jsdom precision quirks
    // don't break the test.
    const ratio = getContrastRatio('#000000', '#808080');
    expect(ratio).toBeGreaterThan(5);
    expect(ratio).toBeLessThan(6);
  });
});

// ─────────────────────────────────────────────────────────────────
// LOW_CONTRAST_THRESHOLD + isLowContrast — merged from colorContrast.js
// on May 23 2026. The semantic carried over from colorContrast was the
// 2.0 threshold value; the NaN-aware fallback is new (the old
// isLowContrast returned `true` on bad input because the old
// contrastRatio returned 1, which the threshold check then flagged
// as low contrast). See colorUtils.js's isLowContrast docblock for
// the full rationale on switching to NaN-returns-false.
// ─────────────────────────────────────────────────────────────────

describe('LOW_CONTRAST_THRESHOLD', () => {
  it('is 2.0', () => {
    // Pinned because the value affects which color combinations
    // trigger downstream warning surfaces. Bumping it (or a future
    // caller using a different threshold) is a deliberate decision,
    // not a stealth refactor.
    expect(LOW_CONTRAST_THRESHOLD).toBe(2.0);
  });
});

describe('isLowContrast', () => {
  describe('threshold behavior', () => {
    it('returns true when the ratio is below the default threshold', () => {
      // Very-similar colors. Two mid-greys with a small luminance
      // delta produce a low ratio well below 2.0.
      expect(isLowContrast('#777777', '#888888')).toBe(true);
    });

    it('returns false when the ratio meets or exceeds the threshold', () => {
      // Pure black on pure white has ratio 21 — nowhere near low.
      expect(isLowContrast('#000000', '#ffffff')).toBe(false);
      // Black on mid-grey is ~5.3, still well above 2.0.
      expect(isLowContrast('#000000', '#808080')).toBe(false);
    });

    it('returns true for identical colors (ratio 1)', () => {
      // Identical colors have ratio exactly 1, which is < 2.0.
      // Useful as a guard against "the user picked the same colour
      // for foreground and background" — the strictest version of
      // the low-contrast case.
      expect(isLowContrast('#ec4899', '#ec4899')).toBe(true);
    });

    it('accepts a custom threshold', () => {
      // A stricter check (WCAG AA for normal text is 4.5) flags more
      // combinations as low-contrast.
      const blackOnGrey = getContrastRatio('#000000', '#808080');
      // ~5.3, so AA-normal (4.5) lets it pass, but a strict 6.0
      // threshold flags it as low.
      expect(isLowContrast('#000000', '#808080', 4.5)).toBe(false);
      expect(isLowContrast('#000000', '#808080', 6.0)).toBe(true);
      expect(blackOnGrey).toBeGreaterThan(4.5);
      expect(blackOnGrey).toBeLessThan(6.0);
    });
  });

  describe('NaN-aware fallback (regression for the merge)', () => {
    // The headline behavioral change from the pre-merge colorContrast
    // module. Old colorContrast.isLowContrast returned TRUE on bad
    // input (contrastRatio fell back to 1 → 1 < 2.0 → true). New
    // isLowContrast returns FALSE on bad input — "unknown contrast,
    // don't warn" rather than "unknown, warn just in case." These
    // tests pin the new behaviour; a future change that switches
    // back to true-on-NaN would fail here.

    it('returns false when fg is malformed', () => {
      expect(isLowContrast('not a color', '#ffffff')).toBe(false);
      expect(isLowContrast('zzzzzz', '#ffffff')).toBe(false);
      expect(isLowContrast(null, '#ffffff')).toBe(false);
      expect(isLowContrast(undefined, '#ffffff')).toBe(false);
    });

    it('returns false when bg is malformed', () => {
      expect(isLowContrast('#000000', 'not a color')).toBe(false);
      expect(isLowContrast('#000000', null)).toBe(false);
    });

    it('returns false when both are malformed', () => {
      expect(isLowContrast('xyz', 'qrs')).toBe(false);   // 3 chars but non-hex
      expect(isLowContrast(null, undefined)).toBe(false);
    });
  });

  describe('symmetry', () => {
    it('argument order does not matter (mirrors getContrastRatio symmetry)', () => {
      // The function's docblock pins (fg, bg) as the canonical order,
      // but the WCAG math is symmetric so swapping must give the same
      // answer. A future refactor that introduced order-dependent
      // logic (e.g. checking foreground luminance specifically) would
      // break this and need to update the docblock.
      const a = isLowContrast('#777777', '#888888');
      const b = isLowContrast('#888888', '#777777');
      expect(a).toBe(b);
    });
  });
});
