import {
  $,
  $$,
  clearEl,
  escapeCss,
  escapeHtml,
  hideEl,
  makeEl,
  onLeftClick,
  removeEl,
  showEl,
  toggleEl,
  whenReady,
  findFirstTextNode,
  disableEl,
  enableEl,
} from '../dom';
import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom';

describe('DOM Utilities', () => {
  const mockSelectors = ['#id', '.class', 'div', '#a .complex--selector:not(:hover)'];
  const hiddenClass = 'hidden';
  const createHiddenElement: Document['createElement'] = (...params: Parameters<Document['createElement']>) => {
    const el = document.createElement(...params);
    el.classList.add(hiddenClass);
    return el;
  };

  describe('$', () => {
    afterEach(() => {
      vi.restoreAllMocks();
    });

    it('should call the native querySelector method on document by default', () => {
      const spy = vi.spyOn(document, 'querySelector');

      mockSelectors.forEach((selector, nthCall) => {
        $(selector);
        expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
      });
    });

    it('should call the native querySelector method on the passed element', () => {
      const mockElement = document.createElement('br');
      const spy = vi.spyOn(mockElement, 'querySelector');

      mockSelectors.forEach((selector, nthCall) => {
        // FIXME This will not be necessary once the file is properly typed
        $(selector, mockElement as unknown as Document);
        expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
      });
    });
  });

  describe('$$', () => {
    afterEach(() => {
      vi.restoreAllMocks();
    });

    it('should call the native querySelectorAll method on document by default', () => {
      const spy = vi.spyOn(document, 'querySelectorAll');

      mockSelectors.forEach((selector, nthCall) => {
        $$(selector);
        expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
      });
    });

    it('should call the native querySelectorAll method on the passed element', () => {
      const mockElement = document.createElement('br');
      const spy = vi.spyOn(mockElement, 'querySelectorAll');

      mockSelectors.forEach((selector, nthCall) => {
        // FIXME This will not be necessary once the file is properly typed
        $$(selector, mockElement as unknown as Document);
        expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
      });
    });
  });

  describe('showEl', () => {
    it(`should remove the ${hiddenClass} class from the provided element`, () => {
      const mockElement = createHiddenElement('div');
      showEl(mockElement);
      expect(mockElement).not.toHaveClass(hiddenClass);
    });

    it(`should remove the ${hiddenClass} class from all provided elements`, () => {
      const mockElements = [createHiddenElement('div'), createHiddenElement('a'), createHiddenElement('strong')];
      showEl(mockElements);
      expect(mockElements[0]).not.toHaveClass(hiddenClass);
      expect(mockElements[1]).not.toHaveClass(hiddenClass);
      expect(mockElements[2]).not.toHaveClass(hiddenClass);
    });

    it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => {
      const mockElements1 = [createHiddenElement('div'), createHiddenElement('a')];
      const mockElements2 = [createHiddenElement('strong'), createHiddenElement('em')];
      showEl(mockElements1, mockElements2);
      expect(mockElements1[0]).not.toHaveClass(hiddenClass);
      expect(mockElements1[1]).not.toHaveClass(hiddenClass);
      expect(mockElements2[0]).not.toHaveClass(hiddenClass);
      expect(mockElements2[1]).not.toHaveClass(hiddenClass);
    });
  });

  describe('hideEl', () => {
    it(`should add the ${hiddenClass} class to the provided element`, () => {
      const mockElement = document.createElement('div');
      hideEl(mockElement);
      expect(mockElement).toHaveClass(hiddenClass);
    });

    it(`should add the ${hiddenClass} class to all provided elements`, () => {
      const mockElements = [
        document.createElement('div'),
        document.createElement('a'),
        document.createElement('strong'),
      ];
      hideEl(mockElements);
      expect(mockElements[0]).toHaveClass(hiddenClass);
      expect(mockElements[1]).toHaveClass(hiddenClass);
      expect(mockElements[2]).toHaveClass(hiddenClass);
    });

    it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => {
      const mockElements1 = [document.createElement('div'), document.createElement('a')];
      const mockElements2 = [document.createElement('strong'), document.createElement('em')];
      hideEl(mockElements1, mockElements2);
      expect(mockElements1[0]).toHaveClass(hiddenClass);
      expect(mockElements1[1]).toHaveClass(hiddenClass);
      expect(mockElements2[0]).toHaveClass(hiddenClass);
      expect(mockElements2[1]).toHaveClass(hiddenClass);
    });
  });

  describe('disableEl', () => {
    it('should set the disabled attribute to true', () => {
      const mockElement = document.createElement('button');
      disableEl(mockElement);
      expect(mockElement).toBeDisabled();
    });

    it('should set the disabled attribute to true on all provided elements', () => {
      const mockElements = [document.createElement('input'), document.createElement('button')];
      disableEl(mockElements);
      expect(mockElements[0]).toBeDisabled();
      expect(mockElements[1]).toBeDisabled();
    });

    it('should set the disabled attribute to true on elements provided in multiple arrays', () => {
      const mockElements1 = [document.createElement('input'), document.createElement('button')];
      const mockElements2 = [document.createElement('textarea'), document.createElement('button')];
      disableEl(mockElements1, mockElements2);
      expect(mockElements1[0]).toBeDisabled();
      expect(mockElements1[1]).toBeDisabled();
      expect(mockElements2[0]).toBeDisabled();
      expect(mockElements2[1]).toBeDisabled();
    });
  });

  describe('enableEl', () => {
    it('should set the disabled attribute to false', () => {
      const mockElement = document.createElement('button');
      enableEl(mockElement);
      expect(mockElement).toBeEnabled();
    });

    it('should set the disabled attribute to false on all provided elements', () => {
      const mockElements = [document.createElement('input'), document.createElement('button')];
      enableEl(mockElements);
      expect(mockElements[0]).toBeEnabled();
      expect(mockElements[1]).toBeEnabled();
    });

    it('should set the disabled attribute to false on elements provided in multiple arrays', () => {
      const mockElements1 = [document.createElement('input'), document.createElement('button')];
      const mockElements2 = [document.createElement('textarea'), document.createElement('button')];
      enableEl(mockElements1, mockElements2);
      expect(mockElements1[0]).toBeEnabled();
      expect(mockElements1[1]).toBeEnabled();
      expect(mockElements2[0]).toBeEnabled();
      expect(mockElements2[1]).toBeEnabled();
    });
  });

  describe('toggleEl', () => {
    it(`should toggle the ${hiddenClass} class on the provided element`, () => {
      const mockVisibleElement = document.createElement('div');
      toggleEl(mockVisibleElement);
      expect(mockVisibleElement).toHaveClass(hiddenClass);

      const mockHiddenElement = createHiddenElement('div');
      toggleEl(mockHiddenElement);
      expect(mockHiddenElement).not.toHaveClass(hiddenClass);
    });

    it(`should toggle the ${hiddenClass} class on all provided elements`, () => {
      const mockElements = [
        document.createElement('div'),
        createHiddenElement('a'),
        document.createElement('strong'),
        createHiddenElement('em'),
      ];
      toggleEl(mockElements);
      expect(mockElements[0]).toHaveClass(hiddenClass);
      expect(mockElements[1]).not.toHaveClass(hiddenClass);
      expect(mockElements[2]).toHaveClass(hiddenClass);
      expect(mockElements[3]).not.toHaveClass(hiddenClass);
    });

    it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => {
      const mockElements1 = [createHiddenElement('div'), document.createElement('a')];
      const mockElements2 = [createHiddenElement('strong'), document.createElement('em')];
      toggleEl(mockElements1, mockElements2);
      expect(mockElements1[0]).not.toHaveClass(hiddenClass);
      expect(mockElements1[1]).toHaveClass(hiddenClass);
      expect(mockElements2[0]).not.toHaveClass(hiddenClass);
      expect(mockElements2[1]).toHaveClass(hiddenClass);
    });
  });

  describe('clearEl', () => {
    it('should not throw an exception for empty element', () => {
      const emptyElement = document.createElement('br');
      expect(emptyElement.children).toHaveLength(0);
      expect(() => clearEl(emptyElement)).not.toThrow();
      expect(emptyElement.children).toHaveLength(0);
    });

    it('should remove a single child node', () => {
      const baseElement = document.createElement('p');
      baseElement.appendChild(document.createElement('br'));
      expect(baseElement.children).toHaveLength(1);
      clearEl(baseElement);
      expect(baseElement.children).toHaveLength(0);
    });

    it('should remove a multiple child nodes', () => {
      const baseElement = document.createElement('p');
      const elementsToAdd = getRandomIntBetween(5, 10);
      for (let i = 0; i < elementsToAdd; ++i) {
        baseElement.appendChild(document.createElement('br'));
      }
      expect(baseElement.children).toHaveLength(elementsToAdd);
      clearEl(baseElement);
      expect(baseElement.children).toHaveLength(0);
    });

    it('should remove child nodes of elements provided in multiple arrays', () => {
      const baseElement1 = document.createElement('p');
      const elementsToAdd1 = getRandomIntBetween(5, 10);
      for (let i = 0; i < elementsToAdd1; ++i) {
        baseElement1.appendChild(document.createElement('br'));
      }
      expect(baseElement1.children).toHaveLength(elementsToAdd1);

      const baseElement2 = document.createElement('p');
      const elementsToAdd2 = getRandomIntBetween(5, 10);
      for (let i = 0; i < elementsToAdd2; ++i) {
        baseElement2.appendChild(document.createElement('br'));
      }
      expect(baseElement2.children).toHaveLength(elementsToAdd2);

      clearEl([baseElement1], [baseElement2]);
      expect(baseElement1.children).toHaveLength(0);
      expect(baseElement2.children).toHaveLength(0);
    });
  });

  describe('removeEl', () => {
    afterEach(() => {
      vi.restoreAllMocks();
    });

    it('should NOT throw error if element has no parent', () => {
      const detachedElement = document.createElement('div');
      expect(() => removeEl(detachedElement)).not.toThrow();
    });

    it('should call the native removeElement method on parent', () => {
      const parentNode = document.createElement('div');
      const childNode = document.createElement('p');
      parentNode.appendChild(childNode);

      const spy = vi.spyOn(parentNode, 'removeChild');

      removeEl(childNode);
      expect(spy).toHaveBeenCalledTimes(1);
      expect(spy).toHaveBeenNthCalledWith(1, childNode);
    });
  });

  describe('makeEl', () => {
    it('should create br tag', () => {
      const el = makeEl('br');
      expect(el.nodeName).toEqual('BR');
    });

    it('should create a script tag', () => {
      const mockSource = 'https://example.com/';
      const el = makeEl('script', { src: mockSource, async: true, defer: true });
      expect(el.nodeName).toEqual('SCRIPT');
      expect(el.src).toEqual(mockSource);
      expect(el.async).toEqual(true);
      expect(el.defer).toEqual(true);
    });

    it('should create a link tag', () => {
      const mockHref = 'https://example.com/';
      const mockTarget = '_blank';
      const el = makeEl('a', { href: mockHref, target: mockTarget });
      expect(el.nodeName).toEqual('A');
      expect(el.href).toEqual(mockHref);
      expect(el.target).toEqual(mockTarget);
    });

    it('should create paragraph tag', () => {
      const mockClassOne = 'class-one';
      const mockClassTwo = 'class-two';
      const el = makeEl('p', { className: `${mockClassOne} ${mockClassTwo}` });
      expect(el.nodeName).toEqual('P');
      expect(el).toHaveClass(mockClassOne);
      expect(el).toHaveClass(mockClassTwo);
    });
  });

  describe('onLeftClick', () => {
    let cleanup: VoidFunction | undefined;

    afterEach(() => {
      if (cleanup) cleanup();
    });

    it('should call callback on left click', () => {
      const mockCallback = vi.fn();
      const element = document.createElement('div');
      cleanup = onLeftClick(mockCallback, element as unknown as Document);

      fireEvent.click(element, { button: 0 });

      expect(mockCallback).toHaveBeenCalledTimes(1);
    });

    it('should NOT call callback on non-left click', () => {
      const mockCallback = vi.fn();
      const element = document.createElement('div');
      cleanup = onLeftClick(mockCallback, element as unknown as Document);

      const mockButton = getRandomArrayItem([1, 2, 3, 4, 5]);
      fireEvent.click(element, { button: mockButton });

      expect(mockCallback).not.toHaveBeenCalled();
    });

    it('should add click event listener to the document by default', () => {
      const mockCallback = vi.fn();
      cleanup = onLeftClick(mockCallback);

      fireEvent.click(document.body);

      expect(mockCallback).toHaveBeenCalledTimes(1);
    });

    it('should return a cleanup function that removes the listener', () => {
      const mockCallback = vi.fn();
      const element = document.createElement('div');
      const localCleanup = onLeftClick(mockCallback, element as unknown as Document);

      fireEvent.click(element, { button: 0 });

      expect(mockCallback).toHaveBeenCalledTimes(1);

      // Remove the listener
      localCleanup();

      fireEvent.click(element, { button: 0 });

      expect(mockCallback).toHaveBeenCalledTimes(1);
    });
  });

  describe('whenReady', () => {
    it('should call callback immediately if document ready state is not loading', () => {
      const mockReadyStateValue = getRandomArrayItem<DocumentReadyState>(['complete', 'interactive']);
      const readyStateSpy = vi.spyOn(document, 'readyState', 'get').mockReturnValue(mockReadyStateValue);
      const mockCallback = vi.fn();

      try {
        whenReady(mockCallback);
        expect(mockCallback).toHaveBeenCalledTimes(1);
      } finally {
        readyStateSpy.mockRestore();
      }
    });

    it('should add event listener with callback if document ready state is loading', () => {
      const readyStateSpy = vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
      const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
      const mockCallback = vi.fn();

      try {
        whenReady(mockCallback);
        expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
        expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback);
        expect(mockCallback).not.toHaveBeenCalled();
      } finally {
        readyStateSpy.mockRestore();
        addEventListenerSpy.mockRestore();
      }
    });
  });

  describe('escapeHtml', () => {
    it('should replace only the expected characters with their HTML entity equivalents', () => {
      expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe(
        '&lt;script src=&quot;http://example.com/?a=1&amp;b=2&quot;&gt;&lt;/script&gt;',
      );
    });
  });

  describe('escapeCss', () => {
    it('should replace only the expected characters with their escaped equivalents', () => {
      expect(escapeCss('url("https://example.com")')).toBe('url(\\"https://example.com\\")');
    });
  });

  describe('findFirstTextNode', () => {
    it('should return the first text node child', () => {
      const mockText = `expected text ${Math.random()}`;
      const mockNode = document.createElement('div');
      mockNode.innerHTML = `<strong>bold</strong>${mockText}<em>italic</em>`;

      const result: Node = findFirstTextNode(mockNode);
      expect(result.nodeValue).toBe(mockText);
    });

    it('should return undefined if there is no text node child', () => {
      const mockNode = document.createElement('div');
      mockNode.innerHTML = '<strong>bold</strong><em>italic</em>';

      const result: Node = findFirstTextNode(mockNode);
      expect(result).toBe(undefined);
    });
  });
});