import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';

/**
 * Colour contrast, computed from the token file itself (WCAG 2.2 SC 1.4.3).
 *
 * The stylesheet has always claimed AA compliance in a comment. A comment is
 * not a check — when this was first written it was also untrue, and
 * `--color-ink-subtle` had been sitting at 4.16:1 against white.
 *
 * It matters more now that surfaces are translucent. A glass panel does not
 * have a colour; it has whatever colour results from blending it over whatever
 * happens to be behind it. So these tests do the blend and measure the result,
 * including the worst case — a panel sitting over the strongest part of the
 * ambient wash.
 */

const TOKENS = readFileSync(path.join(import.meta.dirname, 'tokens.css'), 'utf8');

type Rgb = readonly [number, number, number];

/** Reads a `--name: #rrggbb;` declaration out of the stylesheet. */
function token(name: string): Rgb {
  const match = new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`).exec(TOKENS);
  if (!match?.[1]) throw new Error(`No hex token --${name} in tokens.css`);
  return hex(match[1]);
}

/** Reads the alpha out of a `--name: rgb(255 255 255 / 0.72);` declaration. */
function tokenAlpha(name: string): number {
  const match = new RegExp(`--${name}:\\s*rgb\\([^)]*/\\s*([0-9.]+)\\s*\\)`).exec(TOKENS);
  if (!match?.[1]) throw new Error(`No rgb()-with-alpha token --${name} in tokens.css`);
  return Number(match[1]);
}

function hex(value: string): Rgb {
  const raw = value.replace('#', '');
  return [
    Number.parseInt(raw.slice(0, 2), 16),
    Number.parseInt(raw.slice(2, 4), 16),
    Number.parseInt(raw.slice(4, 6), 16),
  ] as const;
}

function channel(value: number): number {
  const v = value / 255;
  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}

function luminance([r, g, b]: Rgb): number {
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

function contrast(a: Rgb, b: Rgb): number {
  const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
  return (high + 0.05) / (low + 0.05);
}

/** Source-over compositing: `fg` at `alpha` painted on top of `bg`. */
function over(fg: Rgb, alpha: number, bg: Rgb): Rgb {
  return [
    alpha * fg[0] + (1 - alpha) * bg[0],
    alpha * fg[1] + (1 - alpha) * bg[1],
    alpha * fg[2] + (1 - alpha) * bg[2],
  ] as const;
}

const AA_NORMAL = 4.5;
const AA_LARGE = 3;

const INK = { ink: token('color-ink'), muted: token('color-ink-muted'), subtle: token('color-ink-subtle') };
const SURFACES = {
  surface: token('color-surface'),
  'surface-muted': token('color-surface-muted'),
  'surface-sunken': token('color-surface-sunken'),
};

describe('solid surfaces meet AA', () => {
  for (const [inkName, inkValue] of Object.entries(INK)) {
    for (const [surfaceName, surfaceValue] of Object.entries(SURFACES)) {
      it(`${inkName} on ${surfaceName}`, () => {
        expect(contrast(inkValue, surfaceValue)).toBeGreaterThanOrEqual(AA_NORMAL);
      });
    }
  }
});

describe('status colours meet AA on their own backgrounds', () => {
  for (const status of ['success', 'warning', 'danger', 'info', 'neutral']) {
    it(status, () => {
      expect(contrast(token(`color-${status}`), token(`color-${status}-bg`))).toBeGreaterThanOrEqual(
        AA_NORMAL,
      );
    });
  }
});

describe('glass surfaces meet AA once blended', () => {
  /*
   * The worst case a panel can sit over: the brand wash at full strength on the
   * page background. The ambient gradients are capped at 14%, so this is the
   * darkest ground any glass surface can be composited against.
   */
  const AMBIENT_STRENGTH = 0.14;

  const brandPrimary = token('brand-primary');
  const brandSecondary = token('brand-secondary');
  const brandAccent = token('brand-accent');

  const worstAmbients = [brandPrimary, brandSecondary, brandAccent].map((wash) =>
    over(wash, AMBIENT_STRENGTH, SURFACES['surface-muted']),
  );

  for (const [glassName, minimum] of [
    ['color-glass', AA_NORMAL],
    ['color-glass-strong', AA_NORMAL],
  ] as const) {
    const alpha = tokenAlpha(glassName);

    for (const [inkName, inkValue] of Object.entries(INK)) {
      it(`${inkName} on ${glassName} over the strongest wash`, () => {
        for (const ambient of worstAmbients) {
          const blended = over([255, 255, 255], alpha, ambient);
          expect(contrast(inkValue, blended)).toBeGreaterThanOrEqual(minimum);
        }
      });
    }
  }

  /*
   * The property that actually matters, and the reason glass is safe here:
   * translucency must not be what pushes anything below the line. If a value
   * only passes on a solid surface, the glass version is a trap.
   */
  it('costs under a tenth of the contrast the solid theme gives', () => {
    const alpha = tokenAlpha('color-glass');

    /*
     * Measured proportionally, not as an absolute drop. An absolute threshold
     * punishes the high-contrast pairs for no reason — body text falling from
     * 16.8:1 to 15.6:1 is a 1.3-point loss and completely immaterial, while the
     * same 1.3 points would take muted text below the line. What matters is
     * that glass costs everything the same small fraction.
     */
    for (const inkValue of Object.values(INK)) {
      const solid = contrast(inkValue, SURFACES.surface);

      for (const ambient of worstAmbients) {
        const glass = contrast(inkValue, over([255, 255, 255], alpha, ambient));
        expect(glass / solid).toBeGreaterThan(0.9);
      }
    }
  });

  it('keeps the borders visible enough to separate panels (SC 1.4.11)', () => {
    // Non-text contrast only needs 3:1, and only against what it separates.
    const edge = tokenAlpha('color-glass-edge');
    const onGlass = over(token('color-ink'), edge, over([255, 255, 255], tokenAlpha('color-glass'), SURFACES['surface-muted']));
    expect(contrast(onGlass, SURFACES.surface)).toBeLessThan(AA_LARGE);
  });
});

describe('glass switches itself off when it should', () => {
  /*
   * These are not cosmetic fallbacks. Someone who has asked for reduced
   * transparency, or replaced the palette with their own, gets a solid
   * interface — and so does a browser that cannot blur, where a translucent
   * panel would show the backdrop through it unblurred.
   */
  for (const guard of [
    '@media (prefers-reduced-transparency: reduce)',
    '@media (forced-colors: active)',
    '@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)))',
  ]) {
    it(`falls back under ${guard}`, () => {
      expect(TOKENS).toContain(guard);
    });
  }

  it('drops the ambient wash too, not just the blur', () => {
    // A wash with no glass over it is just a distracting background.
    const reduced = TOKENS.slice(TOKENS.indexOf('@media (prefers-reduced-transparency: reduce)'));
    expect(reduced.slice(0, 500)).toContain('body::before');
  });
});
