import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import {
  closestCenter,
  DndContext,
  DragEndEvent,
  DragOverEvent,
  DragStartEvent,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
import {
  type EntryId,
  type MaybeString,
  type Rundown,
  isOntimeBlock,
  isOntimeEvent,
  OntimeBlock,
  OntimeEntry,
  Playback,
  SupportedEntry,
} from 'ontime-types';
import {
  getFirstNormal,
  getLastNormal,
  getNextBlockNormal,
  getNextNormal,
  getPreviousBlockNormal,
  getPreviousNormal,
  reorderArray,
} from 'ontime-utils';

import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';

import QuickAddBlock from './quick-add-block/QuickAddBlock';
import BlockEnd from './rundown-block/BlockEnd';
import RundownBlock from './rundown-block/RundownBlock';
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';

import style from './Rundown.module.scss';

const RundownEntry = lazy(() => import('./RundownEntry'));

interface RundownProps {
  data: Rundown;
}

export default function Rundown({ data }: RundownProps) {
  const { order, entries, id } = data;
  // we create a copy of the rundown with a data structured aligned with what dnd-kit needs
  const featureData = useRundownEditor();
  const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
  const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
    // we ensure that this is unique to the rundown
    key: `rundown.${id}-editor-collapsed-groups`,
    defaultValue: [],
  });

  const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions();

  const { entryCopyId, setEntryCopyId } = useEntryCopy();

  // cursor
  const { mode: appMode } = useAppMode();
  const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();

  const cursorRef = useRef<HTMLDivElement | null>(null);
  const scrollRef = useRef<HTMLDivElement | null>(null);
  useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });

  // DND KIT
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));

  const deleteAtCursor = useCallback(
    (cursor: string | null) => {
      if (!cursor) return;
      const { entry, index } = getPreviousNormal(entries, order, cursor);
      deleteEntry([cursor]);
      if (entry && index !== null) {
        setSelectedEvents({ id: entry.id, selectMode: 'click', index });
      }
    },
    [entries, order, deleteEntry, setSelectedEvents],
  );

  const insertCopyAtId = useCallback(
    (atId: string | null, copyId: string | null, above = false) => {
      const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
      if (copyId === null) {
        // we cant clone without selection
        return;
      }
      const cloneEntry = entries[copyId];
      if (cloneEntry?.type === SupportedEntry.Event) {
        //if we don't have a cursor add the new event on top
        const newEvent = cloneEvent(cloneEntry);
        addEntry(newEvent, { after: adjustedCursor ?? undefined });
      }
    },
    [addEntry, order, entries],
  );

  const insertAtId = useCallback(
    (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
      const options: EventOptions =
        id === null
          ? {}
          : {
              after: above ? undefined : id,
              before: above ? id : undefined,
            };

      if (!above && id) {
        options.lastEventId = id;
      }
      addEntry(patch, options);
    },
    [addEntry],
  );

  const selectBlock = useCallback(
    (cursor: string | null, direction: 'up' | 'down') => {
      if (order.length < 1) {
        return;
      }
      let newCursor = cursor;
      if (cursor === null) {
        // there is no cursor, we select the first or last depending on direction
        const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);

        if (isOntimeBlock(selected)) {
          setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
          return;
        }
        newCursor = selected?.id ?? null;
      }

      if (newCursor === null) {
        return;
      }

      // otherwise we select the next or previous
      const selected =
        direction === 'up'
          ? getPreviousBlockNormal(entries, order, newCursor)
          : getNextBlockNormal(entries, order, newCursor);

      if (selected.entry !== null && selected.index !== null) {
        setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
      }
    },
    [order, entries, setSelectedEvents],
  );

  const selectEntry = useCallback(
    (cursor: string | null, direction: 'up' | 'down') => {
      if (order.length < 1) {
        return;
      }

      if (cursor === null) {
        // there is no cursor, we select the first or last depending on direction if it exists
        const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
        if (selected !== null) {
          setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
        }
        return;
      }

      // otherwise we select the next or previous
      const selected =
        direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);

      if (selected.entry !== null && selected.index !== null) {
        setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
      }
    },
    [order, entries, setSelectedEvents],
  );

  /**
   * Checks whether a block is collapsed
   */
  const getIsCollapsed = useCallback(
    (blockId: EntryId): boolean => {
      return Boolean(collapsedGroups.find((id) => id === blockId));
    },
    [collapsedGroups],
  );

  /**
   * Handles logic for collapsing groups
   */
  const handleCollapseGroup = useCallback(
    (collapsed: boolean, groupId: EntryId) => {
      setCollapsedGroups((prev) => {
        const isCollapsed = getIsCollapsed(groupId);
        if (collapsed && !isCollapsed) {
          const newSet = new Set(prev).add(groupId);
          return [...newSet];
        }
        if (!collapsed && isCollapsed) {
          return [...prev].filter((id) => id !== groupId);
        }
        return prev;
      });
    },
    [getIsCollapsed, setCollapsedGroups],
  );

  const moveEntry = useCallback(
    async (cursor: EntryId | null, direction: 'up' | 'down') => {
      if (cursor == null) {
        return;
      }

      const movedIntoBlockId = await move(cursor, direction);
      // if we are moving into a block, we need to make sure it is expanded
      if (movedIntoBlockId) {
        handleCollapseGroup(false, movedIntoBlockId);
      }
    },
    [handleCollapseGroup, move],
  );

  // shortcuts
  useHotkeys([
    ['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
    ['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],

    ['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
    ['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],

    ['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
    ['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],

    ['Escape', () => clearSelectedEvents(), { preventDefault: true }],

    ['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],

    [
      'alt + E',
      () => insertAtId({ type: SupportedEntry.Event }, cursor),
      { preventDefault: true, usePhysicalKeys: true },
    ],
    ['alt + shift + E', () => insertAtId({ type: SupportedEntry.Event }, cursor, true), { preventDefault: true }],

    [
      'alt + B',
      () => insertAtId({ type: SupportedEntry.Block }, cursor),
      { preventDefault: true, usePhysicalKeys: true },
    ],
    ['alt + shift + B', () => insertAtId({ type: SupportedEntry.Block }, cursor, true), { preventDefault: true }],

    [
      'alt + D',
      () => insertAtId({ type: SupportedEntry.Delay }, cursor),
      { preventDefault: true, usePhysicalKeys: true },
    ],
    ['alt + shift + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor, true), { preventDefault: true }],

    ['mod + C', () => setEntryCopyId(cursor)],
    ['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
    ['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],

    ['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
  ]);

  // we copy the state from the store here
  // to workaround async updates on the drag mutations
  useEffect(() => {
    setSortableData(makeSortableList(order, entries));
  }, [order, entries]);

  // in run mode, we follow selection
  useEffect(() => {
    if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
      return;
    }
    const index = order.findIndex((id) => id === featureData.selectedEventId);
    setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
  }, [appMode, featureData.selectedEventId, order, setSelectedEvents]);

  /**
   * On drag end, we reorder the events
   */
  const handleOnDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    if (!over?.id || active.id === over.id) {
      return;
    }

    const fromIndex = active.data.current?.sortable.index;
    const toIndex = over.data.current?.sortable.index;

    // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
    setSortableData((currentEntries) => {
      return reorderArray(currentEntries, fromIndex, toIndex);
    });

    let destinationId = over.id as EntryId;
    let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';

    /**
     * We need to specially handle the end blocks
     * Dragging before and end block will add the entry to the end of the block
     * Dragging after an end block will add the event after the block itself
     */
    if (destinationId.startsWith('end-')) {
      destinationId = destinationId.replace('end-', '');
      // if we are moving before the end, we use the insert operation
      order = 'insert';
    }

    reorderEntry(active.id as EntryId, destinationId, order);
  };

  /**
   * When we drag a block, we force collapse it
   * This avoids strange scenarios like dropping a block inside itself
   */
  const collapseDraggedBlocks = (event: DragStartEvent) => {
    const isBlock = event.active.data.current?.type === 'block';
    if (isBlock) {
      handleCollapseGroup(true, event.active.id as EntryId);
    }
  };

  /**
   * When we drag over a block, we expand it if it is collapsed
   */
  const expandOverBlock = (event: DragOverEvent) => {
    // if we are dragging a block, the drop operation is invalid so we dont expand
    if (event.active.data.current?.type === 'block') {
      return;
    }
    if (event.over?.data.current?.type !== 'block') {
      return;
    }
    const blockId = event.over?.id as EntryId;
    const isCollapsed = getIsCollapsed(blockId);
    if (isCollapsed) {
      handleCollapseGroup(false, blockId);
    }
  };

  if (sortableData.length < 1) {
    return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
  }

  // 1. gather presentation options
  const isEditMode = appMode === AppMode.Edit;

  // 2. initialise rundown metadata
  const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
  // keep a single reference to the metadata which we override for every entry
  let rundownMetadata = metadata;

  return (
    <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
      <DndContext
        onDragEnd={handleOnDragEnd}
        onDragStart={collapseDraggedBlocks}
        onDragOver={expandOverBlock}
        sensors={sensors}
        collisionDetection={closestCenter}
      >
        <SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
          <div className={style.list}>
            {sortableData.map((entryId, index) => {
              const isFirst = index === 0;
              const isLast = index === sortableData.length - 1;

              // the entry might be a pseudo block-end which does not generate metadata and should not be processed
              if (entryId.startsWith('end-')) {
                const parentId = entryId.split('end-')[1];
                const isBlockCollapsed = getIsCollapsed(parentId);

                if (isBlockCollapsed && isEditMode && isLast) {
                  return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
                } else if (isBlockCollapsed) {
                  return null;
                } else {
                  const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
                  // if the previous element is selected, it will have its own QuickAddBlock
                  // we use thisId instead of previousEntryId because the block end does not process
                  // and it does not cause the reassignment of the iteration id to the previous entry
                  const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId;
                  return (
                    <Fragment key={entryId}>
                      {showPrependingQuickAdd && (
                        <QuickAddBlock
                          previousEventId={rundownMetadata.thisId}
                          parentBlock={parentId}
                          backgroundColor={parentColour}
                        />
                      )}
                      <BlockEnd key={entryId} id={entryId} colour={parentColour} />
                      {isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
                    </Fragment>
                  );
                }
              }

              // we iterate through a stateful copy of order to make the dnd operations smoother
              // this means that this can be out of sync with order until the useEffect runs
              // instead of writing all the logic guards, we simply short circuit rendering here
              const entry = entries[entryId];
              if (!entry) return null;
              rundownMetadata = process(entry);

              // if the entry has a parent, and it is collapsed, render nothing
              if (
                entry.type !== SupportedEntry.Block &&
                rundownMetadata.groupId !== null &&
                getIsCollapsed(rundownMetadata.groupId)
              ) {
                return null;
              }

              const isNext = featureData?.nextEventId === entry.id;
              const hasCursor = entry.id === cursor;

              /**
               * Outside a block, the value will be undefined
               * If the colour is empty string ''
               * ie: we are inside a block, but there is no defined colour
               * we default to $gray-1050 #303030
               */
              const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;

              return (
                <Fragment key={entry.id}>
                  {isEditMode && (hasCursor || isFirst) && (
                    <QuickAddBlock
                      previousEventId={rundownMetadata.previousEntryId}
                      parentBlock={isFirst ? null : rundownMetadata.groupId}
                      backgroundColor={isFirst ? undefined : blockColour}
                    />
                  )}
                  {isOntimeBlock(entry) ? (
                    <RundownBlock
                      data={entry}
                      hasCursor={hasCursor}
                      collapsed={getIsCollapsed(entry.id)}
                      onCollapse={handleCollapseGroup}
                    />
                  ) : (
                    <div
                      className={style.entryWrapper}
                      data-testid={`entry-${rundownMetadata.eventIndex}`}
                      style={blockColour ? { '--user-bg': blockColour } : {}}
                    >
                      {isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
                      <div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
                        <RundownEntry
                          type={entry.type}
                          isPast={rundownMetadata.isPast}
                          eventIndex={rundownMetadata.eventIndex}
                          data={entry}
                          loaded={rundownMetadata.isLoaded}
                          hasCursor={hasCursor}
                          isNext={isNext}
                          previousEntryId={rundownMetadata.previousEntryId}
                          previousEventId={rundownMetadata.previousEvent?.id}
                          playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
                          isRolling={featureData.playback === Playback.Roll}
                          isNextDay={rundownMetadata.isNextDay}
                          totalGap={rundownMetadata.totalGap}
                          isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
                        />
                      </div>
                    </div>
                  )}
                  {isEditMode && (hasCursor || isLast) && (
                    <QuickAddBlock
                      previousEventId={entry.id}
                      parentBlock={rundownMetadata.groupId}
                      backgroundColor={blockColour}
                    />
                  )}
                </Fragment>
              );
            })}
            <div className={style.spacer} />
          </div>
        </SortableContext>
      </DndContext>
    </div>
  );
}
