import { clearDragSource, initDraggables } from '../draggable';
import { fireEvent } from '@testing-library/dom';
import { getRandomArrayItem } from '../../../test/randomness';
import { MockInstance } from 'vitest';

describe('Draggable Utilities', () => {
  // jsdom lacks proper support for window.DragEvent so this is an attempt at a minimal recreation
  const createDragEvent = (name: string, init?: DragEventInit): DragEvent => {
    const mockEvent = new Event(name, { bubbles: true, cancelable: true }) as unknown as DragEvent;
    let dataTransfer = init?.dataTransfer;
    if (!dataTransfer) {
      const items: Pick<DataTransferItem, 'type' | 'getAsString'>[] = [];
      dataTransfer = {
        items: items as unknown as DataTransferItemList,
        setData(format: string, data: string) {
          items.push({ type: format, getAsString: (callback: FunctionStringCallback) => callback(data) });
        },
      } as unknown as DataTransfer;
    }
    Object.assign(mockEvent, { dataTransfer });
    return mockEvent;
  };

  const createDraggableElement = (): HTMLDivElement => {
    const el = document.createElement('div');
    el.setAttribute('draggable', 'true');
    return el;
  };

  describe('initDraggables', () => {
    const draggingClass = 'dragging';
    const dragContainerClass = 'drag-container';
    const dragOverClass = 'over';
    let documentEventListenerSpy: MockInstance;

    let mockDragContainer: HTMLDivElement;
    let mockDraggable: HTMLDivElement;

    beforeEach(() => {
      mockDragContainer = document.createElement('div');
      mockDragContainer.classList.add(dragContainerClass);
      document.body.appendChild(mockDragContainer);

      mockDraggable = createDraggableElement();
      mockDragContainer.appendChild(mockDraggable);

      // Redirect all document event listeners to this element for easier cleanup
      documentEventListenerSpy = vi.spyOn(document, 'addEventListener').mockImplementation((...params) => {
        mockDragContainer.addEventListener(...params);
      });
    });

    afterEach(() => {
      document.body.removeChild(mockDragContainer);
      documentEventListenerSpy.mockRestore();
    });

    describe('dragStart', () => {
      it('should add the dragging class to the element that starts moving', () => {
        initDraggables();

        const mockEvent = createDragEvent('dragstart');

        fireEvent(mockDraggable, mockEvent);

        expect(mockDraggable).toHaveClass(draggingClass);
      });

      it("should add dummy data to the dragstart event if it's empty", () => {
        initDraggables();

        const mockEvent = createDragEvent('dragstart');
        expect(mockEvent.dataTransfer?.items).toHaveLength(0);

        fireEvent(mockDraggable, mockEvent);

        expect(mockEvent.dataTransfer?.items).toHaveLength(1);

        const dataTransferItem = (mockEvent.dataTransfer as DataTransfer).items[0];
        expect(dataTransferItem.type).toEqual('text/plain');

        let stringValue: string | undefined;
        dataTransferItem.getAsString(value => {
          stringValue = value;
        });
        expect(stringValue).toEqual('');
      });

      it("should keep data in the dragstart event if it's present", () => {
        initDraggables();

        const mockTransferItemType = getRandomArrayItem(['text/javascript', 'image/jpg', 'application/json']);
        const mockDataTransferItem: DataTransferItem = {
          type: mockTransferItemType,
        } as unknown as DataTransferItem;

        const mockEvent = createDragEvent('dragstart', {
          dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList },
        } as DragEventInit);
        expect(mockEvent.dataTransfer?.items).toHaveLength(1);

        fireEvent(mockDraggable, mockEvent);

        expect(mockEvent.dataTransfer?.items).toHaveLength(1);

        const dataTransferItem = (mockEvent.dataTransfer as DataTransfer).items[0];
        expect(dataTransferItem.type).toEqual(mockTransferItemType);
      });

      it('should set the allowed effect to move on the data transfer', () => {
        initDraggables();

        const mockEvent = createDragEvent('dragstart');
        expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();

        fireEvent(mockDraggable, mockEvent);

        expect(mockEvent.dataTransfer?.effectAllowed).toEqual('move');
      });

      it('should not throw if the event has no dataTransfer property', () => {
        initDraggables();

        const mockEvent = createDragEvent('dragstart');
        delete (mockEvent as Record<keyof typeof mockEvent, unknown>).dataTransfer;
        expect(() => fireEvent(mockDraggable, mockEvent)).not.toThrow();
      });
    });

    describe('dragOver', () => {
      it('should cancel event and set the drop effect to move on the data transfer', () => {
        initDraggables();

        const mockEvent = createDragEvent('dragover');

        fireEvent(mockDraggable, mockEvent);

        expect(mockEvent.defaultPrevented).toBe(true);
        expect(mockEvent.dataTransfer?.dropEffect).toEqual('move');
      });
    });

    describe('dragEnter', () => {
      it('should add the over class to the target', () => {
        initDraggables();

        const mockEvent = createDragEvent('dragenter');

        fireEvent(mockDraggable, mockEvent);

        expect(mockDraggable).toHaveClass(dragOverClass);
      });
    });

    describe('dragLeave', () => {
      it('should remove the over class from the target', () => {
        initDraggables();

        mockDraggable.classList.add(dragOverClass);
        const mockEvent = createDragEvent('dragleave');

        fireEvent(mockDraggable, mockEvent);

        expect(mockDraggable).not.toHaveClass(dragOverClass);
      });
    });

    describe('drop', () => {
      it('should cancel the event and remove dragging class if dropped on same element', () => {
        initDraggables();

        const mockStartEvent = createDragEvent('dragstart');
        fireEvent(mockDraggable, mockStartEvent);

        expect(mockDraggable).toHaveClass(draggingClass);

        const mockDropEvent = createDragEvent('drop');
        fireEvent(mockDraggable, mockDropEvent);

        expect(mockDropEvent.defaultPrevented).toBe(true);
        expect(mockDraggable).not.toHaveClass(draggingClass);
      });

      it('should cancel the event and insert source before target if dropped on left side', () => {
        initDraggables();

        const mockSecondDraggable = createDraggableElement();
        mockDragContainer.appendChild(mockSecondDraggable);

        const mockStartEvent = createDragEvent('dragstart');
        fireEvent(mockSecondDraggable, mockStartEvent);

        expect(mockSecondDraggable).toHaveClass(draggingClass);

        const mockDropEvent = createDragEvent('drop');
        Object.assign(mockDropEvent, { clientX: 124 });
        const boundingBoxSpy = vi.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
          left: 100,
          width: 50,
        } as unknown as DOMRect);
        fireEvent(mockDraggable, mockDropEvent);

        try {
          expect(mockDropEvent.defaultPrevented).toBe(true);
          expect(mockSecondDraggable).not.toHaveClass(draggingClass);
          expect(mockSecondDraggable.nextElementSibling).toBe(mockDraggable);
        } finally {
          boundingBoxSpy.mockRestore();
        }
      });

      it('should cancel the event and insert source after target if dropped on right side', () => {
        initDraggables();

        const mockSecondDraggable = createDraggableElement();
        mockDragContainer.appendChild(mockSecondDraggable);

        const mockStartEvent = createDragEvent('dragstart');
        fireEvent(mockSecondDraggable, mockStartEvent);

        expect(mockSecondDraggable).toHaveClass(draggingClass);

        const mockDropEvent = createDragEvent('drop');
        Object.assign(mockDropEvent, { clientX: 125 });
        const boundingBoxSpy = vi.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
          left: 100,
          width: 50,
        } as unknown as DOMRect);
        fireEvent(mockDraggable, mockDropEvent);

        try {
          expect(mockDropEvent.defaultPrevented).toBe(true);
          expect(mockSecondDraggable).not.toHaveClass(draggingClass);
          expect(mockDraggable.nextElementSibling).toBe(mockSecondDraggable);
        } finally {
          boundingBoxSpy.mockRestore();
        }
      });

      it('should not throw if drag source element is not set', () => {
        clearDragSource();
        initDraggables();

        const mockSecondDraggable = createDraggableElement();
        mockDragContainer.appendChild(mockSecondDraggable);

        const mockDropEvent = createDragEvent('drop');
        fireEvent(mockDraggable, mockDropEvent);

        expect(() => fireEvent(mockDraggable, mockDropEvent)).not.toThrow();
        expect(mockDropEvent.defaultPrevented).toBe(true);
      });
    });

    describe('dragEnd', () => {
      it("should remove dragging class from source and over class from target's descendants", () => {
        initDraggables();

        const mockStartEvent = createDragEvent('dragstart');
        fireEvent(mockDraggable, mockStartEvent);

        expect(mockDraggable).toHaveClass(draggingClass);

        const mockOverElement = createDraggableElement();
        mockOverElement.classList.add(dragOverClass);
        mockDraggable.parentNode?.appendChild(mockOverElement);
        const mockOverEvent = createDragEvent('dragend');
        fireEvent(mockOverElement, mockOverEvent);

        const mockDropEvent = createDragEvent('dragend');
        fireEvent(mockDraggable, mockDropEvent);

        expect(mockDraggable).not.toHaveClass(draggingClass);
        expect(mockOverElement).not.toHaveClass(dragOverClass);
      });
    });

    describe('wrapper', () => {
      it('should do nothing when event target has no closest method', () => {
        initDraggables();

        const mockEvent = createDragEvent('dragstart');
        Object.assign(mockDraggable, { closest: undefined });

        fireEvent(mockDraggable, mockEvent);

        expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
      });

      it('should do nothing when event target does not have a parent matching the predefined selector', () => {
        initDraggables();

        const mockEvent = createDragEvent('dragstart');
        const draggableClosestSpy = vi.spyOn(mockDraggable, 'closest').mockReturnValue(null);

        try {
          fireEvent(mockDraggable, mockEvent);

          expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
        } finally {
          draggableClosestSpy.mockRestore();
        }
      });
    });
  });
});