import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { scrollProgress } from './scroll-progress.js';
import { mountHost, connectTrait, spyEvent, resetDOM } from '../test-helpers.js';

describe('scroll-progress', () => {
  let originalRAF;
  let originalCAF;

  beforeEach(() => {
    resetDOM();
    // Synchronous rAF — the trait's scheduler calls back immediately so
    // we can assert post-update side-effects without awaiting frames.
    originalRAF = globalThis.requestAnimationFrame;
    originalCAF = globalThis.cancelAnimationFrame;
    globalThis.requestAnimationFrame = (fn) => { fn(performance.now?.() ?? 0); return 1; };
    globalThis.cancelAnimationFrame = vi.fn();
  });

  afterEach(() => {
    globalThis.requestAnimationFrame = originalRAF;
    globalThis.cancelAnimationFrame = originalCAF;
  });

  it('connect sets data-scroll-progress-active', () => {
    const host = mountHost();
    connectTrait(scrollProgress, host);
    expect(host.hasAttribute('data-scroll-progress-active')).toBe(true);
  });

  it('disconnect clears active + progress + CSS variable', () => {
    const host = mountHost();
    const inst = connectTrait(scrollProgress, host);
    inst.disconnect(host);
    expect(host.hasAttribute('data-scroll-progress-active')).toBe(false);
    expect(host.hasAttribute('data-scroll-progress')).toBe(false);
    expect(host.style.getPropertyValue('--scroll-progress')).toBe('');
  });

  it('writes data-scroll-progress as a 0..1 string after connect', () => {
    const host = mountHost();
    connectTrait(scrollProgress, host);
    // Synchronous rAF means the seed update has already run.
    const v = host.getAttribute('data-scroll-progress');
    expect(v).not.toBeNull();
    const n = parseFloat(v);
    expect(n).toBeGreaterThanOrEqual(0);
    expect(n).toBeLessThanOrEqual(1);
  });

  it('writes the --scroll-progress CSS custom property', () => {
    const host = mountHost();
    connectTrait(scrollProgress, host);
    const css = host.style.getPropertyValue('--scroll-progress');
    expect(css).not.toBe('');
    const n = parseFloat(css);
    expect(n).toBeGreaterThanOrEqual(0);
    expect(n).toBeLessThanOrEqual(1);
  });

  it('dispatches a scroll-progress event with { progress } detail', () => {
    const host = mountHost();
    const spy = spyEvent(host, 'scroll-progress');
    connectTrait(scrollProgress, host);
    // Initial seed fires once via synchronous rAF.
    expect(spy.count).toBeGreaterThanOrEqual(1);
    expect(typeof spy.last.progress).toBe('number');
    expect(spy.last.progress).toBeGreaterThanOrEqual(0);
    expect(spy.last.progress).toBeLessThanOrEqual(1);
  });

  it('mode="page" falls back to 0 when scroll range is zero', () => {
    const host = mountHost('div', { 'data-scroll-progress-mode': 'page' });
    connectTrait(scrollProgress, host);
    // happy-dom reports zero scroll geometry by default.
    expect(host.getAttribute('data-scroll-progress')).toBe('0.000');
  });

  it('mode="scrolled" reports 0..1 from host scrollTop / max', () => {
    const host = mountHost('div', { 'data-scroll-progress-mode': 'scrolled' });
    // Stub the geometry — happy-dom returns zeros for scroll metrics.
    Object.defineProperty(host, 'scrollHeight', { configurable: true, value: 1000 });
    Object.defineProperty(host, 'clientHeight', { configurable: true, value: 200 });
    Object.defineProperty(host, 'scrollTop', { configurable: true, value: 200, writable: true });
    connectTrait(scrollProgress, host);
    const v = parseFloat(host.getAttribute('data-scroll-progress'));
    // 200 / (1000 - 200) = 0.25
    expect(v).toBeCloseTo(0.25, 2);
  });

  it('mode="in-view" clamps to [0,1] for offscreen rects', () => {
    const host = mountHost();
    // Far below the viewport.
    host.getBoundingClientRect = () => ({ top: 99999, bottom: 100099, left: 0, right: 0, width: 0, height: 100, x: 0, y: 99999, toJSON() { return {}; } });
    connectTrait(scrollProgress, host);
    expect(parseFloat(host.getAttribute('data-scroll-progress'))).toBe(0);
  });

  it('mode="in-view" reports 1 for rects fully scrolled past', () => {
    const host = mountHost();
    // Far above the viewport.
    host.getBoundingClientRect = () => ({ top: -2000, bottom: -1900, left: 0, right: 0, width: 0, height: 100, x: 0, y: -2000, toJSON() { return {}; } });
    // Make sure window.innerHeight is sane.
    Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
    connectTrait(scrollProgress, host);
    expect(parseFloat(host.getAttribute('data-scroll-progress'))).toBe(1);
  });

  it('data-scroll-container selector resolves an explicit ancestor', () => {
    document.body.innerHTML = '<div id="custom-scroller" style="overflow:auto;"><div id="inner"></div></div>';
    const host = document.getElementById('inner');
    host.setAttribute('data-scroll-container', '#custom-scroller');
    const scroller = document.getElementById('custom-scroller');
    const addSpy = vi.spyOn(scroller, 'addEventListener');
    connectTrait(scrollProgress, host);
    // The trait should attach a scroll listener to the resolved container.
    const calls = addSpy.mock.calls.filter(([type]) => type === 'scroll');
    expect(calls.length).toBeGreaterThanOrEqual(1);
  });

  it('invalid data-scroll-container selector falls through to ancestor walk', () => {
    document.body.innerHTML = '<div id="parent" style="overflow:auto;"><div id="child"></div></div>';
    const host = document.getElementById('child');
    host.setAttribute('data-scroll-container', '###not-a-valid-selector');
    expect(() => connectTrait(scrollProgress, host)).not.toThrow();
  });

  it('schedules through rAF — second scroll within the same frame coalesces', () => {
    let queued = 0;
    let pending = null;
    globalThis.requestAnimationFrame = (fn) => {
      queued++;
      pending = fn;
      return queued;
    };
    globalThis.cancelAnimationFrame = vi.fn();
    const host = mountHost();
    const inst = connectTrait(scrollProgress, host);
    const baseline = queued;
    // Fire two scroll events back-to-back without flushing the rAF.
    window.dispatchEvent(new Event('scroll'));
    window.dispatchEvent(new Event('scroll'));
    // Only one rAF should be queued in addition to the seed.
    expect(queued - baseline).toBeLessThanOrEqual(1);
    // Flush manually so disconnect has nothing pending.
    if (pending) pending(0);
    inst.disconnect(host);
  });

  it('disconnect cancels the pending rAF', () => {
    let pendingId = null;
    globalThis.requestAnimationFrame = () => { pendingId = 42; return 42; };
    const cancelSpy = vi.fn();
    globalThis.cancelAnimationFrame = cancelSpy;
    const host = mountHost();
    const inst = connectTrait(scrollProgress, host);
    inst.disconnect(host);
    // The trait queued one rAF on connect; disconnect should cancel it.
    expect(cancelSpy).toHaveBeenCalled();
  });

  it('disconnect removes the scroll listener from the resolved container', () => {
    document.body.innerHTML = '<div id="scr" style="overflow:auto;"><div id="h"></div></div>';
    const host = document.getElementById('h');
    host.setAttribute('data-scroll-container', '#scr');
    const scroller = document.getElementById('scr');
    const removeSpy = vi.spyOn(scroller, 'removeEventListener');
    const inst = connectTrait(scrollProgress, host);
    inst.disconnect(host);
    const calls = removeSpy.mock.calls.filter(([type]) => type === 'scroll');
    expect(calls.length).toBeGreaterThanOrEqual(1);
  });

  it('reconnect after disconnect does not throw and restores the active flag', () => {
    const host = mountHost();
    const inst1 = connectTrait(scrollProgress, host);
    inst1.disconnect(host);
    const inst2 = connectTrait(scrollProgress, host);
    expect(host.hasAttribute('data-scroll-progress-active')).toBe(true);
    inst2.disconnect(host);
    expect(host.hasAttribute('data-scroll-progress-active')).toBe(false);
  });
});
