import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { Renderer } from '@/types/view';

// Vite serves fixture files; fetch the EPUB at runtime in the browser.
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;

let book: BookDoc;

const loadEPUB = async () => {
  const resp = await fetch(EPUB_URL);
  const buffer = await resp.arrayBuffer();
  const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
  const loader = new DocumentLoader(file);
  const { book } = await loader.open();
  return book;
};

/**
 * Wait for the paginator to emit 'stabilized'.
 * MUST be called BEFORE the action that triggers stabilization (e.g. goTo),
 * because #display dispatches 'stabilized' synchronously before returning.
 */
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
    el.addEventListener(
      'stabilized',
      () => {
        clearTimeout(timer);
        resolve();
      },
      { once: true },
    );
  });

/** Wait until `getContents().length >= n` or timeout. */
const waitForViews = async (el: Renderer, n: number, timeout = 10000) => {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    if (el.getContents().length >= n) return;
    await new Promise((r) => setTimeout(r, 100));
  }
};

/** Wait for fill to complete by polling until getContents count stabilizes. */
const waitForFillComplete = async (el: Renderer, timeout = 10000) => {
  const start = Date.now();
  let lastCount = -1;
  let stableFor = 0;
  while (Date.now() - start < timeout) {
    const count = el.getContents().length;
    if (count === lastCount) {
      stableFor += 100;
      if (stableFor >= 500) return;
    } else {
      stableFor = 0;
      lastCount = count;
    }
    await new Promise((r) => setTimeout(r, 100));
  }
};

describe('Paginator stabilization (browser)', () => {
  let paginator: Renderer;

  // Suppress unhandled errors from paginator's #replaceBackground firing
  // after views are destroyed (queued iframe loads from rapid navigation).
  // This is a known paginator cleanup race, not a test failure.
  const suppressHandler = (e: ErrorEvent) => {
    if (e.message?.includes('getComputedStyle')) e.preventDefault();
  };

  beforeAll(async () => {
    window.addEventListener('error', suppressHandler);
    book = await loadEPUB();
    await import('foliate-js/paginator.js');
  }, 30000);

  afterAll(() => {
    window.removeEventListener('error', suppressHandler);
  });

  const createPaginator = () => {
    const el = document.createElement('foliate-paginator') as Renderer;
    Object.assign(el.style, {
      width: '800px',
      height: '600px',
      position: 'absolute',
      left: '0',
      top: '0',
    });
    document.body.appendChild(el);
    return el;
  };

  /** Create paginator, open book, navigate to section, wait for stabilized. */
  const setupAt = async (index: number) => {
    paginator = createPaginator();
    paginator.open(book);
    const stabilized = waitForStabilized(paginator);
    await paginator.goTo({ index });
    await stabilized;
  };

  afterEach(async () => {
    if (paginator) {
      // Flush pending RAFs and iframe load callbacks before destroying.
      // Rapid navigation tests can leave queued callbacks that reference
      // views — destroying immediately would cause unhandled errors in
      // paginator.js (#replaceBackground accessing destroyed documents).
      await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
      try {
        paginator.destroy();
      } catch {
        /* iframe body may already be torn down */
      }
      paginator.remove();
    }
  });

  describe('Stabilized event lifecycle', () => {
    it('should emit stabilized event after goTo completes', async () => {
      paginator = createPaginator();
      paginator.open(book);
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');
      let stabilizedCount = 0;
      paginator.addEventListener('stabilized', () => {
        stabilizedCount++;
      });
      const stabilized = waitForStabilized(paginator);
      await paginator.goTo({ index: idx });
      await stabilized;
      expect(stabilizedCount).toBeGreaterThanOrEqual(1);
    });

    it('should emit stabilized for each navigation to a new section', async () => {
      paginator = createPaginator();
      paginator.open(book);
      const linearSections = book
        .sections!.map((s, i) => ({ s, i }))
        .filter(({ s }) => s.linear !== 'no');
      expect(linearSections.length).toBeGreaterThan(1);

      let stabilizedCount = 0;
      paginator.addEventListener('stabilized', () => {
        stabilizedCount++;
      });

      const s1 = waitForStabilized(paginator);
      await paginator.goTo({ index: linearSections[0]!.i });
      await s1;
      const countAfterFirst = stabilizedCount;
      expect(countAfterFirst).toBeGreaterThanOrEqual(1);

      // Navigate to a far section so view is not reused
      const farIdx = linearSections[Math.min(5, linearSections.length - 1)]!.i;
      const s2 = waitForStabilized(paginator);
      await paginator.goTo({ index: farIdx });
      await s2;
      expect(stabilizedCount).toBeGreaterThan(countAfterFirst);
    });

    it('should have content visible (opacity=1) after stabilized', async () => {
      paginator = createPaginator();
      paginator.open(book);
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');

      let containerOpacity = '';
      paginator.addEventListener('stabilized', () => {
        // Access shadow DOM container to check opacity
        const container = paginator.shadowRoot?.getElementById('container');
        containerOpacity = container?.style.opacity ?? '';
      });

      const stabilized = waitForStabilized(paginator);
      await paginator.goTo({ index: idx });
      await stabilized;
      // After stabilized, opacity should be '1' (visible)
      expect(containerOpacity).toBe('1');
    });
  });

  describe('Container opacity during stabilization', () => {
    it('should set opacity to 0 at start and 1 at end of display', async () => {
      paginator = createPaginator();
      paginator.open(book);
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');

      const opacities: string[] = [];

      // Observe the shadow DOM container for style changes
      const container = paginator.shadowRoot?.getElementById('container');
      if (container) {
        const observer = new MutationObserver(() => {
          opacities.push(container.style.opacity);
        });
        observer.observe(container, { attributes: true, attributeFilter: ['style'] });
      }

      const stabilized = waitForStabilized(paginator);
      await paginator.goTo({ index: idx });
      await stabilized;

      // Should have seen opacity transitions: 0 → 1
      expect(opacities).toContain('0');
      expect(opacities).toContain('1');
      // The last opacity should be '1' (content visible)
      const lastOpacity = opacities[opacities.length - 1];
      expect(lastOpacity).toBe('1');
    });
  });

  describe('Font loading integration', () => {
    it('should load view even when document has no custom fonts', async () => {
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');
      await setupAt(idx);
      // View should be loaded with a valid document
      const contents = paginator.getContents();
      const primary = contents.find((c) => c.index === idx);
      expect(primary).toBeDefined();
      expect(primary!.doc.fonts).toBeDefined();
    });
  });

  describe('Render micro-stabilization', () => {
    it('should emit stabilized when render is called on a loaded paginator', async () => {
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');
      await setupAt(idx);
      // Wait for fill to fully complete so #stabilizing is false
      await waitForFillComplete(paginator);

      const stabilizedFromRender = waitForStabilized(paginator);
      paginator.render?.();
      // render() dispatches stabilized in a RAF
      await stabilizedFromRender;
      // If we get here, stabilized was emitted
      expect(true).toBe(true);
    });

    it('should preserve primaryIndex across re-renders', async () => {
      const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
      await setupAt(idx);
      await waitForFillComplete(paginator);

      const indexBefore = paginator.primaryIndex;
      const stabilized = waitForStabilized(paginator);
      paginator.render?.();
      await stabilized;
      expect(paginator.primaryIndex).toBe(indexBefore);
    });
  });

  describe('Stabilization suppresses scroll-to-anchor', () => {
    it('should not emit extra relocate events during initial fill', async () => {
      paginator = createPaginator();
      paginator.open(book);
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');
      const relocates: Array<{ index: number; fraction: number; reason: string }> = [];
      paginator.addEventListener('relocate', ((e: CustomEvent) => {
        relocates.push(e.detail);
      }) as EventListener);

      const stabilized = waitForStabilized(paginator);
      await paginator.goTo({ index: idx });
      await stabilized;
      // Wait for fill to complete
      await waitForFillComplete(paginator);

      // All relocate events should reference valid section indices
      for (const r of relocates) {
        expect(r.index).toBeGreaterThanOrEqual(0);
        expect(r.index).toBeLessThan(book.sections!.length);
      }
      // Primary index should still be the one we navigated to
      expect(paginator.primaryIndex).toBe(idx);
    });
  });

  describe('Fill visible area completes after stabilized', () => {
    it('should have more views after fill completes than at stabilized time', async () => {
      paginator = createPaginator();
      paginator.open(book);
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');

      let viewsAtStabilized = 0;
      paginator.addEventListener(
        'stabilized',
        () => {
          viewsAtStabilized = paginator.getContents().length;
        },
        { once: true },
      );

      const stabilized = waitForStabilized(paginator);
      await paginator.goTo({ index: idx });
      await stabilized;
      // Wait for fill to complete
      await waitForFillComplete(paginator);

      const viewsAfterFill = paginator.getContents().length;
      // Fill should have loaded additional sections beyond what was
      // available at stabilized time (or at least the same count if
      // the book has very few sections)
      expect(viewsAfterFill).toBeGreaterThanOrEqual(viewsAtStabilized);
    });

    it('should block backward loading while stabilizing', async () => {
      const linearSections = book
        .sections!.map((s, i) => ({ s, i }))
        .filter(({ s }) => s.linear !== 'no');

      // Navigate to a middle section so there are sections before
      const midIdx = linearSections[Math.min(3, linearSections.length - 1)]!.i;
      await setupAt(midIdx);
      // After stabilized, primary should still be the target
      expect(paginator.primaryIndex).toBe(midIdx);
    });
  });

  describe('Scrolled mode stabilization', () => {
    it('should stabilize correctly in scrolled mode', async () => {
      paginator = createPaginator();
      paginator.open(book);
      paginator.setAttribute('flow', 'scrolled');
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');

      const stabilized = waitForStabilized(paginator);
      await paginator.goTo({ index: idx });
      await stabilized;

      expect(paginator.scrolled).toBe(true);
      expect(paginator.primaryIndex).toBe(idx);
      expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
    });

    it('should pre-load previous section in scrolled mode', async () => {
      paginator = createPaginator();
      paginator.open(book);
      paginator.setAttribute('flow', 'scrolled');
      const linearSections = book
        .sections!.map((s, i) => ({ s, i }))
        .filter(({ s }) => s.linear !== 'no');

      // Navigate to second linear section with anchor=0 (top)
      if (linearSections.length > 1) {
        const secondIdx = linearSections[1]!.i;
        const stabilized = waitForStabilized(paginator);
        await paginator.goTo({ index: secondIdx, anchor: 0 });
        await stabilized;
        // In scrolled mode, previous section is pre-loaded in #display
        await waitForViews(paginator, 2);
        const contents = paginator.getContents();
        const indices = contents.map((c) => c.index);
        // Should have loaded the previous section
        const firstIdx = linearSections[0]!.i;
        expect(indices).toContain(firstIdx);
      }
    });
  });

  describe('Attribute-triggered re-render', () => {
    it('should re-stabilize when flow attribute changes', async () => {
      const idx = book.sections!.findIndex((s) => s.linear !== 'no');
      await setupAt(idx);
      await waitForFillComplete(paginator);

      // Switch to scrolled mode — should trigger render() and stabilization
      const stabilizedFromSwitch = waitForStabilized(paginator);
      paginator.setAttribute('flow', 'scrolled');
      await stabilizedFromSwitch;
      expect(paginator.scrolled).toBe(true);
    });

    it('should preserve primaryIndex when switching flow modes', async () => {
      const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
      await setupAt(idx);
      await waitForFillComplete(paginator);

      const indexBefore = paginator.primaryIndex;

      // Switch to scrolled
      const s1 = waitForStabilized(paginator);
      paginator.setAttribute('flow', 'scrolled');
      await s1;
      expect(paginator.primaryIndex).toBe(indexBefore);

      // Wait for fill to complete before switching back
      await waitForFillComplete(paginator);

      // Switch back to paginated
      const s2 = waitForStabilized(paginator);
      paginator.removeAttribute('flow');
      await s2;
      expect(paginator.primaryIndex).toBe(indexBefore);
    });
  });

  describe('Multiple rapid navigations', () => {
    it('should settle on the last navigation target after rapid goTo calls', async () => {
      paginator = createPaginator();
      paginator.open(book);
      const linearSections = book
        .sections!.map((s, i) => ({ s, i }))
        .filter(({ s }) => s.linear !== 'no');
      expect(linearSections.length).toBeGreaterThan(2);

      // Fire multiple navigations rapidly
      const first = linearSections[0]!.i;
      const second = linearSections[1]!.i;
      const third = linearSections[Math.min(2, linearSections.length - 1)]!.i;

      // Don't await intermediate — fire them all
      paginator.goTo({ index: first });
      paginator.goTo({ index: second });
      await paginator.goTo({ index: third });
      // Wait long enough for all background RAF callbacks to flush
      // (setStyles triggers RAF → #replaceBackground which reads computed styles)
      await waitForFillComplete(paginator);

      // The paginator should have settled on a valid section
      expect(paginator.primaryIndex).toBeGreaterThanOrEqual(0);
      expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
    });
  });
});
