import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createSnippet } from '@core/snippet';
import { useSnippetStore } from '../stores/SnippetStore';
import { SnippetLibrary } from './SnippetLibrary';

// StorageMonitor (rendered at the bottom of the pane) measures storage and fetches
// an async estimate on mount; stub the whole component to nothing so its async
// setState doesn't fire outside act() and its legend text doesn't collide with
// library assertions. This suite is about the library, not the monitor.
vi.mock('./StorageMonitor', () => ({ default: () => null }));

// React 19 wants this flag set for act() to drive effects without warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

/** The metadata-panel name field — scoped so the new search input doesn't shadow it. */
const nameInput = () =>
  document.querySelector('section[aria-label="Snippet details"] input') as HTMLInputElement;

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
  useSnippetStore.getState().reset();
  container = document.createElement('div');
  document.body.appendChild(container);
  root = createRoot(container);
});

afterEach(() => {
  act(() => root.unmount());
  container.remove();
  vi.useRealTimers();
});

/** Set a controlled input/textarea's value the way React expects, then fire input. */
function typeInto(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
  const proto =
    el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
  // The native value setter bypasses React 19's input value tracking so the
  // synthetic input event registers as a real change.
  // eslint-disable-next-line @typescript-eslint/unbound-method
  const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
  setter.call(el, value);
  el.dispatchEvent(new Event('input', { bubbles: true }));
}

describe('SnippetLibrary metadata panel (spec §02)', () => {
  test('renders the active snippet name, comment, and linked datasets without looping', async () => {
    const s = {
      ...createSnippet({ id: 'a', name: 'Bar chart', now: new Date('2026-01-01T00:00:00Z') }),
      comment: 'a note',
      datasetRefs: ['Sales'],
    };
    useSnippetStore.getState().hydrate([s], 'a');

    // If the auto-save effect looped, this act() would throw "Maximum update depth".
    await act(async () => {
      root.render(<SnippetLibrary />);
      await Promise.resolve();
    });

    const name = nameInput();
    const comment = container.querySelector('textarea') as HTMLTextAreaElement;
    expect(name.value).toBe('Bar chart');
    expect(comment.value).toBe('a note');
    expect(container.textContent).toContain('Linked datasets');
    expect(container.textContent).toContain('Sales');
  });

  test('auto-saves an inline name edit after the debounce', () => {
    vi.useFakeTimers();
    const s = createSnippet({ id: 'a', name: 'Old', now: new Date('2026-01-01T00:00:00Z') });
    useSnippetStore.getState().hydrate([s], 'a');

    act(() => {
      root.render(<SnippetLibrary />);
    });

    const name = nameInput();
    act(() => typeInto(name, 'Renamed'));
    // Before the debounce fires, the store is unchanged.
    expect(useSnippetStore.getState().snippets[0].name).toBe('Old');

    act(() => {
      vi.advanceTimersByTime(500);
    });
    expect(useSnippetStore.getState().snippets[0].name).toBe('Renamed');
  });

  test('a publish-derived rename is adopted by the panel, not reverted by its auto-save', () => {
    // Regression: the panel's local name state lagged a publish rename, so its
    // debounced auto-save wrote the stale default back — the rename flickered
    // for ~400ms in the list and then undid itself.
    vi.useFakeTimers();
    const s = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); // auto name
    useSnippetStore.getState().hydrate([s], 'a');

    act(() => {
      root.render(<SnippetLibrary />);
    });

    act(() => {
      useSnippetStore
        .getState()
        .updateDraft(
          JSON.stringify({
            mark: 'bar',
            encoding: { x: { field: 'Region' }, y: { aggregate: 'count' } },
          }),
        );
      useSnippetStore.getState().publish(new Date('2026-02-01T00:00:00Z'));
    });

    expect(nameInput().value).toBe('Bar chart of count by Region');
    act(() => {
      vi.advanceTimersByTime(1000); // any pending auto-save settles
    });
    expect(useSnippetStore.getState().snippets[0].name).toBe('Bar chart of count by Region');
  });

  test('a name edit in progress survives a publish rename (user text wins)', () => {
    vi.useFakeTimers();
    const s = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
    useSnippetStore.getState().hydrate([s], 'a');

    act(() => {
      root.render(<SnippetLibrary />);
    });

    act(() => typeInto(nameInput(), 'My Chart')); // diverged, debounce pending
    act(() => {
      useSnippetStore
        .getState()
        .updateDraft(
          JSON.stringify({
            mark: 'bar',
            encoding: { x: { field: 'Region' }, y: { aggregate: 'count' } },
          }),
        );
      useSnippetStore.getState().publish();
    });

    expect(nameInput().value).toBe('My Chart'); // not clobbered by the derived name
    act(() => {
      vi.advanceTimersByTime(1000);
    });
    expect(useSnippetStore.getState().snippets[0].name).toBe('My Chart');
  });

  test('does not loop on a search/sort state change (render-loop guard)', async () => {
    // A selector that returned a fresh filtered array would re-render forever
    // (MEMORY → "Zustand stable selectors"); the component derives via useMemo.
    const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
    const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') });
    useSnippetStore.getState().hydrate([a, b], 'a');

    await act(async () => {
      root.render(<SnippetLibrary />);
      await Promise.resolve();
    });

    // Each store change below would throw "Maximum update depth" inside act() if
    // a render loop existed. Bounded, finite settling = no loop.
    await act(async () => {
      useSnippetStore.getState().setSearch('beta');
      await Promise.resolve();
    });
    await act(async () => {
      useSnippetStore.getState().setSort('name');
      await Promise.resolve();
    });

    // The list reflects the final derived view (only Beta matches "beta").
    const names = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
    expect(names.some((t) => t.includes('Beta'))).toBe(true);
    expect(names.some((t) => t.includes('Alpha'))).toBe(false);
  });

  test('search filters the list and the clear control restores it + refocuses input', () => {
    const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
    const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') });
    useSnippetStore.getState().hydrate([a, b], 'a');

    act(() => {
      root.render(<SnippetLibrary />);
    });

    const search = container.querySelector('input[type="search"]') as HTMLInputElement;
    act(() => typeInto(search, 'alpha'));
    expect(useSnippetStore.getState().searchQuery).toBe('alpha');

    let rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
    expect(rows.some((t) => t.includes('Alpha'))).toBe(true);
    expect(rows.some((t) => t.includes('Beta'))).toBe(false);

    const clear = container.querySelector('button[aria-label="Clear search"]') as HTMLButtonElement;
    act(() => clear.click());
    expect(useSnippetStore.getState().searchQuery).toBe('');
    // Clear returns focus to the input (council SEARCH).
    expect(document.activeElement).toBe(container.querySelector('input[type="search"]'));

    rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
    expect(rows.some((t) => t.includes('Beta'))).toBe(true);
  });

  test('shows the no-matches empty state when a search filters everything out', () => {
    const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
    useSnippetStore.getState().hydrate([a], 'a');

    act(() => {
      root.render(<SnippetLibrary />);
    });

    const search = container.querySelector('input[type="search"]') as HTMLInputElement;
    act(() => typeInto(search, 'zzz-no-match'));
    expect(container.textContent).toContain('No snippets match your search');
  });

  test('Duplicate adds an independent copy and makes it active', async () => {
    const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') });
    useSnippetStore.getState().hydrate([s], 'a');

    await act(async () => {
      root.render(<SnippetLibrary />);
      await Promise.resolve();
    });

    const dup = [...container.querySelectorAll('button')].find(
      (b) => b.textContent === 'Duplicate',
    )!;
    await act(async () => {
      dup.click();
      await Promise.resolve();
    });

    const { snippets, activeSnippetId } = useSnippetStore.getState();
    expect(snippets).toHaveLength(2);
    const active = snippets.find((x) => x.id === activeSnippetId)!;
    expect(active.name).toBe('Chart (copy)');
    expect(active.id).not.toBe('a');
  });
});
