/**
 * spinner-ui tests — covers SPEC-001 § Verification.
 *
 * The component is a CSS-only animation (no stamped DOM, no JS timers).
 * Tests fall in two categories:
 *
 *   1. Behavioral — props reflect to host attributes, defaults apply,
 *      ARIA wiring is in place, element is not a focus target.
 *   2. CSS source — happy-dom doesn't resolve @scope rules through
 *      getComputedStyle(), so the size-ladder, tone-mapping, and
 *      reduced-motion fallback are validated against the CSS source
 *      directly (same recipe as tag-ui / description-list / text).
 */

import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const HERE = dirname(fileURLToPath(import.meta.url));
const SPINNER_CSS = readFileSync(resolve(HERE, 'spinner.css'), 'utf8');

const tick = () => new Promise((r) => queueMicrotask(r));

beforeAll(async () => {
  await import('./spinner.js');
});

function mount(html) {
  const wrap = document.createElement('div');
  wrap.innerHTML = html;
  document.body.appendChild(wrap);
  return wrap.firstElementChild;
}

// ── 1. Defaults + property reflection ──────────────────────────────────

describe('spinner-ui — defaults', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('renders without props using documented defaults', async () => {
    const s = mount('<spinner-ui></spinner-ui>');
    await tick();
    expect(s.size).toBe('md');
    expect(s.variant).toBe('arc');
    expect(s.tone).toBe('current');
    expect(s.paused).toBe(false);
    expect(s.label).toBe('Loading');
  });

  it('sets role="progressbar" on the host', async () => {
    const s = mount('<spinner-ui></spinner-ui>');
    await tick();
    expect(s.getAttribute('role')).toBe('progressbar');
  });

  it('sets aria-busy="true" on the host', async () => {
    const s = mount('<spinner-ui></spinner-ui>');
    await tick();
    expect(s.getAttribute('aria-busy')).toBe('true');
  });

  it('sets aria-valuetext to the default label', async () => {
    const s = mount('<spinner-ui></spinner-ui>');
    await tick();
    expect(s.getAttribute('aria-valuetext')).toBe('Loading');
  });
});

// ── 2. Reflected props ─────────────────────────────────────────────────

describe('spinner-ui — reflected attributes', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('reflects size="sm" / "lg" to the host attribute', async () => {
    const sm = mount('<spinner-ui size="sm"></spinner-ui>');
    await tick();
    expect(sm.getAttribute('size')).toBe('sm');

    const lg = mount('<spinner-ui size="lg"></spinner-ui>');
    await tick();
    expect(lg.getAttribute('size')).toBe('lg');
  });

  it('reflects variant="ring" / "dots" to the host attribute', async () => {
    const ring = mount('<spinner-ui variant="ring"></spinner-ui>');
    await tick();
    expect(ring.getAttribute('variant')).toBe('ring');

    const dots = mount('<spinner-ui variant="dots"></spinner-ui>');
    await tick();
    expect(dots.getAttribute('variant')).toBe('dots');
  });

  it('reflects tone="subtle" / "accent" / "inverse" to the host attribute', async () => {
    const subtle  = mount('<spinner-ui tone="subtle"></spinner-ui>');
    const accent  = mount('<spinner-ui tone="accent"></spinner-ui>');
    const inverse = mount('<spinner-ui tone="inverse"></spinner-ui>');
    await tick();
    expect(subtle.getAttribute('tone')).toBe('subtle');
    expect(accent.getAttribute('tone')).toBe('accent');
    expect(inverse.getAttribute('tone')).toBe('inverse');
  });

  it('reflects [paused] as a boolean attribute', async () => {
    const s = mount('<spinner-ui paused></spinner-ui>');
    await tick();
    expect(s.hasAttribute('paused')).toBe(true);
    expect(s.paused).toBe(true);
  });
});

// ── 3. Accessibility wiring ────────────────────────────────────────────

describe('spinner-ui — accessibility', () => {
  beforeEach(() => { document.body.innerHTML = ''; });

  it('mirrors a custom [label] to aria-valuetext', async () => {
    const s = mount('<spinner-ui label="Saving"></spinner-ui>');
    await tick();
    expect(s.getAttribute('aria-valuetext')).toBe('Saving');
  });

  it('updates aria-valuetext when label is set programmatically', async () => {
    const s = mount('<spinner-ui></spinner-ui>');
    await tick();
    s.label = 'Uploading';
    await tick();
    expect(s.getAttribute('aria-valuetext')).toBe('Uploading');
  });

  it('is NOT a focus target (no tabindex set, body tabIndex chain skips it)', async () => {
    const s = mount('<spinner-ui></spinner-ui>');
    await tick();
    expect(s.hasAttribute('tabindex')).toBe(false);
    // tabIndex defaults to -1 for non-focusable elements in happy-dom
    expect(s.tabIndex).toBeLessThan(0);
  });

  it('does not stamp any internal DOM (static template = null)', async () => {
    const s = mount('<spinner-ui></spinner-ui>');
    await tick();
    // The visual is entirely pseudo-element-driven; the host has no
    // element children of its own.
    expect(s.children.length).toBe(0);
  });

  it('preserves user-supplied role / aria-busy when set in markup', async () => {
    const s = mount('<spinner-ui role="status" aria-busy="false"></spinner-ui>');
    await tick();
    // connected() only sets the default ARIA when absent
    expect(s.getAttribute('role')).toBe('status');
    expect(s.getAttribute('aria-busy')).toBe('false');
  });
});

// ── 4. CSS source contract ─────────────────────────────────────────────
// happy-dom doesn't resolve @scope rules through getComputedStyle, so we
// validate the CSS source shape directly. Same recipe as tag-ui.

describe('spinner-ui — CSS source contract', () => {
  it('opens with the canonical @scope (spinner-ui) block', () => {
    expect(SPINNER_CSS).toMatch(/^@scope \(spinner-ui\)/);
  });

  it('declares both the :where(:scope) token block and a :scope base block', () => {
    expect(SPINNER_CSS).toMatch(/:where\(:scope\)\s*\{/);
    expect(SPINNER_CSS).toMatch(/^\s*:scope\s*\{/m);
  });

  it('declares all 7 component tokens', () => {
    for (const tok of [
      '--spinner-size',
      '--spinner-color',
      '--spinner-stroke',
      '--spinner-duration',
      '--spinner-track-opacity',
      '--spinner-dot-size',
      '--spinner-dot-gap',
    ]) {
      expect(SPINNER_CSS).toContain(tok);
    }
  });

  it('size="sm" / "md" / "lg" all set --spinner-size', () => {
    // Size variants re-point the active --spinner-size token; consumers
    // override the same un-suffixed token on the element (or @layer overrides).
    expect(SPINNER_CSS).toMatch(/:scope\[size="sm"\][^}]*--spinner-size:\s*0\.875rem/);
    expect(SPINNER_CSS).toMatch(/:scope\[size="md"\][^}]*--spinner-size:\s*1rem/);
    expect(SPINNER_CSS).toMatch(/:scope\[size="lg"\][^}]*--spinner-size:\s*1\.25rem/);
  });

  it('tone="subtle" / "accent" / "inverse" set --spinner-color via semantic tokens', () => {
    expect(SPINNER_CSS).toMatch(/:scope\[tone="subtle"\][^}]*--spinner-color:\s*var\(--a-fg-subtle\)/);
    expect(SPINNER_CSS).toMatch(/:scope\[tone="accent"\][^}]*--spinner-color:\s*var\(--a-accent-strong\)/);
    expect(SPINNER_CSS).toMatch(/:scope\[tone="inverse"\][^}]*--spinner-color:\s*var\(--a-chrome-light\)/);
  });

  it('arc variant uses a rotating quarter-circle border on ::before', () => {
    expect(SPINNER_CSS).toMatch(/:scope\[variant="arc"\]::before/);
    // The arc colors three borders transparent + the top currentColor
    expect(SPINNER_CSS).toMatch(/border-color:\s*currentColor\s+transparent\s+transparent\s+transparent/);
    expect(SPINNER_CSS).toMatch(/animation:\s*spinner-ui-rotate/);
  });

  it('ring variant uses a full ring with one rotating colored segment', () => {
    expect(SPINNER_CSS).toMatch(/:scope\[variant="ring"\]::before/);
    expect(SPINNER_CSS).toMatch(/border-top-color:\s*currentColor/);
  });

  it('dots variant uses 3 stamped [data-spinner-dot] children with bounce keyframes', () => {
    // Pre-fix: ::before painted dot1 + a box-shadow middle dot, ::after
    // painted dot3. Spacing was uneven (box-shadow doesn't participate
    // in flex-gap math) and the middle dot couldn't animate. Post-fix
    // (spinner.class.js #syncDots): three real <span data-spinner-dot=N>
    // children stamped on connect; flex-gap distributes the row evenly
    // and each child carries an independent animation-delay so the
    // bounce reads as a left-to-right wave.
    expect(SPINNER_CSS).toMatch(/:scope\[variant="dots"\]\s*>\s*\[data-spinner-dot\]/);
    expect(SPINNER_CSS).toMatch(/:scope\[variant="dots"\]\s*>\s*\[data-spinner-dot="1"\]/);
    expect(SPINNER_CSS).toMatch(/:scope\[variant="dots"\]\s*>\s*\[data-spinner-dot="2"\]/);
    expect(SPINNER_CSS).toMatch(/:scope\[variant="dots"\]\s*>\s*\[data-spinner-dot="3"\]/);
    expect(SPINNER_CSS).toMatch(/animation:\s*spinner-ui-bounce/);
  });

  it('knight variant uses a sliding ::before thumb with knight keyframes', () => {
    expect(SPINNER_CSS).toMatch(/:scope\[variant="knight"\]\s*\{/);
    expect(SPINNER_CSS).toMatch(/:scope\[variant="knight"\]::before/);
    expect(SPINNER_CSS).toMatch(/animation:\s*spinner-ui-knight/);
    // Keyframe math: translateX((1/ratio - 1) * 100%) sweeps the thumb
    // from x=0 to x=(track - thumb) px, expressed in thumb-relative %.
    expect(SPINNER_CSS).toMatch(/@keyframes\s+spinner-ui-knight/);
  });

  it('knight stamps NO data-spinner-dot children (pseudo-element paint only)', async () => {
    const k = mount('<spinner-ui variant="knight"></spinner-ui>');
    await tick();
    expect(k.querySelectorAll(':scope > [data-spinner-dot]').length).toBe(0);
  });

  it('dots stamps exactly 3 data-spinner-dot children on connect', async () => {
    const d = mount('<spinner-ui variant="dots"></spinner-ui>');
    await tick();
    const dots = d.querySelectorAll(':scope > [data-spinner-dot]');
    expect(dots.length).toBe(3);
    expect(dots[0].getAttribute('data-spinner-dot')).toBe('1');
    expect(dots[1].getAttribute('data-spinner-dot')).toBe('2');
    expect(dots[2].getAttribute('data-spinner-dot')).toBe('3');
  });

  it('paused state freezes the animation via animation-play-state', () => {
    expect(SPINNER_CSS).toMatch(/:scope\[paused\][^}]*animation-play-state:\s*paused/);
  });

  it('reduced-motion media query replaces the animation with a static ellipsis', () => {
    expect(SPINNER_CSS).toMatch(/@media\s*\(prefers-reduced-motion:\s*reduce\)/);
    expect(SPINNER_CSS).toMatch(/content:\s*"…"/);
  });

  it('uses semantic --a-* tokens for color tones; no raw hex / rgb / oklch in the file', () => {
    // Lints adjacent to the token contract: no raw colors in component CSS.
    expect(SPINNER_CSS).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
    expect(SPINNER_CSS).not.toMatch(/\brgb\s*\(/);
    expect(SPINNER_CSS).not.toMatch(/\boklch\s*\(/);
  });

  it('declares a linear rotation timing function (load-bearing for smooth spin)', () => {
    expect(SPINNER_CSS).toMatch(/spinner-ui-rotate[^;]*linear/);
  });
});
