import * as rangy from 'rangy';
import 'rangy/lib/rangy-textrange';
import log from 'loglevel';
import {
  AddHighlightArgument,
  AddContentHighlightArgument,
  HighlightableNode,
  HighlightNode,
} from '@/__generated__/graphql';
import popover from '@/helpers/popover';
import { doCreateContentHighlight, doCreateHighlight, doDeleteHighlight, doUpdateHighlight } from '@/graphql/mutations';
import { StoreObject } from '@apollo/client/cache';
import { DocumentNode } from 'graphql';
import Mark from 'mark.js';
import { graphql } from '@/__generated__';
import highlightSidebar from './highlight-sidebar';

// todo: we need to get the following information for a highlight:
// Which ContentBlock? => contentBlock.id
// Which ContentComponent inside the ContentBlock? => Index of contentBlock.contents
// Which paragraph inside the Element
// Which start and endpoint of the selection? Or rather start and length?
// Also, we probably want to store the text of the selection at least, and maybe even some more for context?

export interface Highlight {
  page: string; // the id
  contentIndex?: number; // which content of the .contents array do I come from?
  contentUuid?: string; // the wagtail UUID of the content element
  paragraphIndex: number; // which paragraph inside the textBlock do I come from?
  startPosition: number; // start of the selection
  selectionLength: number; // length of the selection
  text: string; // text that was actually selected
  color?: string;
}

const TOP_TRESHOLD = 0;
const BOTTOM_TRESHOLD = window.innerHeight || document.documentElement.clientHeight;

const isInsideViewport = (position: number) => {
  return position >= TOP_TRESHOLD && position <= BOTTOM_TRESHOLD;
};

const isVisibleInViewport = (rect: DOMRect) => {
  return (
    isInsideViewport(rect.top) || // top of element is inside viewport
    isInsideViewport(rect.bottom) || // bottom of element is inside viewport
    (rect.top <= TOP_TRESHOLD && rect.bottom >= BOTTOM_TRESHOLD) // element spans whole viewport, starts before and ends after
  );
};

const findClosestAncestorWithTag = (node: Node, tags: string | string[]): HTMLElement | null => {
  let tagList = tags;
  if (!Array.isArray(tagList)) {
    tagList = [tagList];
  }
  let currentNode: Node | null = node;
  while (
    currentNode &&
    !tagList.includes(currentNode.nodeName.toLowerCase()) &&
    currentNode.nodeName.toLowerCase() !== 'body'
  ) {
    currentNode = currentNode.parentNode;
  }
  if (currentNode && currentNode.nodeName.toLowerCase() !== 'body') {
    return currentNode as HTMLElement;
  }
  return null;
};

const findClosestAncestorWithClass = (node: Node, className: string): HTMLElement | null => {
  let currentNode: Node | null = node;
  while (
    currentNode && // current node exists
    // current node is not an HTMLElement, so probably a TextNode
    // or, current node does not contain the class
    (!(currentNode instanceof HTMLElement) || !currentNode.classList.contains(className)) &&
    // we have not arrived at the top yet
    currentNode.nodeName.toLowerCase() !== 'body'
  ) {
    // so, we continue looking
    currentNode = currentNode.parentNode;
  }
  if (currentNode instanceof HTMLElement && currentNode.classList.contains(className)) {
    // make sure we're not up at the body element
    return currentNode as HTMLElement;
  }
  return null;
};

const findPositionInParent = (element: HTMLElement, className: string = 'content-component'): number => {
  /*
   * currently, a content block has the structure
   * div.content-block__container
   *  div.content-block
   *    div.content-block__visibility
   *    div.content-block__title
   *    div.content-component
   *    div.content-component
   *    [...]
   *    div.content-component
   *
   * We only care about the .content-component divs, not the others
   *
   */

  if (!element.parentElement) {
    log.error('element has no parent', element);
    return -1; // element has no parent, probably somehow ended up at the top. But this should probably never happen in our application
  }
  const children = Array.from(element.parentElement.children).filter((child) => child.classList.contains(className));
  return children.indexOf(element);
};

const getParentElement = (element: HTMLElement): HTMLElement | null => {
  if (element.tagName.toLowerCase() === 'li') {
    return element.parentElement?.parentElement || null;
  } else {
    return element.parentElement || null;
  }
};

const getSiblings = (element: HTMLElement): Element[] => {
  const parent = getParentElement(element);
  if (!parent) {
    throw Error('element has no parent, this should not be possible');
  }
  let children: Element[] = [];
  for (const child of parent.children) {
    if (child.tagName.toLowerCase() === 'ul') {
      children = [...children, ...child.children];
    } else {
      children = [...children, child];
    }
  }
  return children;
};

export interface SelectionHandlerOptions {
  el: HTMLElement;
  page: HighlightableNode;
  onChangeColor?: (highlight: any) => void;
  onCreateNote?: (highlight: any) => void;
  parentSelector?: string;
}

export type SelectionHandlerType = (event: Event) => void;

export const getSelectionHandler =
  ({ el, page, onChangeColor, onCreateNote, parentSelector }: SelectionHandlerOptions): SelectionHandlerType =>
  // (el: HTMLElement, contentBlock: ContentBlockNode, onUpdateHighlight: (highlight: any) => void = () => {}) =>
  (_e: Event) => {
    const rect = el.getBoundingClientRect();
    if (isVisibleInViewport(rect)) {
      // the listener only does something if the `el` is visible in the viewport, to save resources
      // now we check if the selection is inside our container
      const selection = rangy.getSelection();
      if (selection.rangeCount && !selection.isCollapsed) {
        // ⬆️ sat least one selection exists and is not of length 0
        const range = selection.getRangeAt(0);
        if (range.intersectsNode(el)) {
          // the selection is inside our container, so we can do something with it
          const { startContainer, endContainer } = range;
          // todo: handle case with paragraphs and list items
          const div_ancestor = findClosestAncestorWithTag(startContainer, 'div');
          if (div_ancestor && isNotHighlightable(div_ancestor)) {
            return;
          }
          const startAncestor = findClosestAncestorWithTag(startContainer, ['p', 'li']);
          // const endAncestor = findClosestAncestorWithTag(endContainer, 'p');
          if (!startAncestor) {
            // there was no ancestor found, but this can be the case when there's a subtitle or something similar
            // selected
            return;
          }
          // should have the same tag as the start item
          const endAncestor = findClosestAncestorWithTag(endContainer, startAncestor.tagName.toLowerCase());

          if (startAncestor === endAncestor) {
            const selector = parentSelector ? parentSelector : 'content-component';
            const contentComponent = findClosestAncestorWithClass(startContainer, selector);
            if (contentComponent) {
              // our selection is wholly inside the container node, we continue with it
              const position = findPositionInParent(contentComponent);
              const uuid = contentComponent.dataset.uuid || '';

              // const siblings = Array.from(startAncestor.parentElement?.children || []);
              const siblings = getSiblings(startAncestor);
              const positionInTextBlock = siblings.indexOf(startAncestor);

              const { start, end } = range.toCharacterRange(startAncestor);
              const highlightedText: Highlight = {
                page: page.id,
                startPosition: start,
                paragraphIndex: positionInTextBlock,
                selectionLength: end - start,
                text: range.toString(),
              };

              if (uuid > '') {
                highlightedText.contentIndex = position;
                highlightedText.contentUuid = uuid;
              }

              const positionOfContentBlock = el.getBoundingClientRect();
              const positionOfSelection = startAncestor?.getBoundingClientRect();
              const offsetTop = positionOfSelection.top - positionOfContentBlock.top;
              const onChooseColor = (color: string) => {
                highlightedText.color = color;
                if (onChangeColor) {
                  onChangeColor(highlightedText);
                }
              };
              const onNote = () => {
                highlightedText.color = 'alpha';
                if (onCreateNote) {
                  onCreateNote(highlightedText);
                }
              };
              popover.show({
                wrapper: el,
                offsetTop,
                onChooseColor,
                onNote,
              });
            }
          }
        }
      }
    }
  };

const isNotHighlightable = (element: HTMLElement) => {
  const nonHighlightableClasses = ['solution__hidden'];
  return [...element.classList].some((value) => nonHighlightableClasses.includes(value));
};

export const replacePopover = (highlight: HighlightNode) => {
  // we need to replace the Popover after the Highlight is created
  // todo: is there a cleaner way than to look for the element and click it?
  const mark = document.querySelector(`mark[data-id="${highlight.id}"]`);
  if (mark) {
    mark.click();
  }
};

interface CreateHighlightOptions {
  fragment: DocumentNode;
  fragmentName: string;
  cacheSignature: StoreObject;
  isContentHighlight: boolean; // if it is, then we need other arguments
}

// todo: convert to async?
export const createHighlightCurry =
  ({ fragment, fragmentName, cacheSignature, isContentHighlight }: CreateHighlightOptions) =>
  async (highlight: AddHighlightArgument | AddContentHighlightArgument) => {
    const createHighlightFunction = isContentHighlight ? doCreateContentHighlight : doCreateHighlight;
    const { data } = await createHighlightFunction(
      {
        input: {
          highlight,
        },
      },
      {
        update: (cache, { data }) => {
          const id = cache.identify(cacheSignature);
          log.debug(id);
          const fragmentWithHighlights = cache.readFragment({
            id,
            fragment,
            fragmentName,
          });
          log.debug(fragmentWithHighlights);
          const highlight = isContentHighlight ? data?.addContentHighlight?.highlight : data?.addHighlight?.highlight;
          log.debug(highlight);
          if (highlight) {
            cache.writeFragment({
              id,
              fragment,
              fragmentName,
              data: {
                ...fragmentWithHighlights,
                highlights: [
                  ...(fragmentWithHighlights?.highlights.filter((h) => h.id !== highlight.id) || []),
                  highlight,
                ],
              },
            });
          }
        },
      }
    );
    const highlightResult = isContentHighlight ? data?.addContentHighlight?.highlight : data?.addHighlight?.highlight;
    if (highlightResult) {
      replacePopover(highlightResult);
    }
    return highlightResult;
  };

const onUpdateTextCurry = (highlight: HighlightNode) => (text: string) =>
  // curry function for updating the highlight note
  doUpdateHighlight({
    input: {
      note: text,
      id: highlight.id,
    },
  });

interface ClickListenerCurryOptions {
  wrapper: HTMLElement; // the element the popover will be the direct child of, which has relative positioning
  parent: HTMLElement; // the element (paragraph/list) the highlight is inside
  highlight: HighlightNode;
}

const markClickListenerCurry =
  ({ wrapper, parent, highlight }: ClickListenerCurryOptions) =>
  () => {
    if (wrapper) {
      const onChooseColor = async (color: string) => {
        try {
          const {
            data: {
              updateHighlight: { highlight: newHighlight },
            },
          } = await doUpdateHighlight({
            input: {
              color: color,
              id: highlight.id,
            },
          });

          // we need to refresh the sidebar
          highlightSidebar.open({
            highlight: newHighlight,
            onUpdateText: onUpdateTextCurry(newHighlight),
          });
        } catch (error) {
          console.error('Error updating highlight:', error);
        }
      };

      const top = parent.getBoundingClientRect().top - wrapper.getBoundingClientRect().top;
      popover.show({
        wrapper: wrapper,
        offsetTop: top,
        onChooseColor,
        onDelete: deleteHighlightCurry(highlight),
      });
    }
    highlightSidebar.open({
      highlight,
      onUpdateText: onUpdateTextCurry(highlight),
    });
  };

export const markHighlight = (highlight: HighlightNode, element: HTMLElement, popoverElement: HTMLElement) => {
  const instance = new Mark(element);
  const ranges: Mark.Range[] = [
    {
      start: highlight.startPosition,
      length: highlight.selectionLength,
    },
  ];
  const className = `highlight highlight--${highlight.color}`;
  instance.markRanges(ranges, {
    className,
    // createMark and add Event Listener
    each: (newMark: HTMLElement) => {
      newMark.dataset.id = highlight.id;
      newMark.dataset.cy = 'highlight-mark';
      newMark.addEventListener(
        'click',
        markClickListenerCurry({
          wrapper: popoverElement,
          parent: element,
          highlight,
        })
      );
    },
  });
};

// type them out, so the static codegen can do its work
const fragments = {
  InstrumentNode: graphql(`
    fragment InstrumentHighlightsWithIdOnlyFragment on InstrumentNode {
      highlights {
        id
      }
    }
  `),
  ChapterNode: graphql(`
    fragment ChapterHighlightsWithIdOnlyFragment on ChapterNode {
      highlights {
        id
      }
    }
  `),
  ModuleNode: graphql(`
    fragment ModuleHighlightsWithIdOnlyFragment on ModuleNode {
      highlights {
        id
      }
    }
  `),
  ContentBlockNode: graphql(`
    fragment ContentBlockHighlightsWithIdOnlyFragment on ContentBlockNode {
      highlights {
        id
      }
    }
  `),
};

const getCacheProperties = (cache, page, typename, identifier) => {
  log.debug(`page is a ${typename}, remove highlights`);
  const fragment = fragments[typename];
  const id = cache.identify({
    __typename: typename,
    [identifier]: page[identifier],
  });
  log.debug(`id found: ${id}`);
  log.debug(`fragment: ${fragment}`);
  log.debug(fragment);
  return { fragment, id };
};

const cacheIdentifiers = {
  InstrumentNode: 'slug',
  ModuleNode: 'slug',
  ChapterNode: 'id',
  ContentBlockNode: 'id',
};

const getFragment = (cache, page: HighlightableNode) => {
  const typename = page.__typename as string;
  const identifier = cacheIdentifiers[typename];
  const { fragment, id } = getCacheProperties(cache, page, typename, identifier);
  const foundFragment = cache.readFragment({
    fragment,
    id,
  });
  return { fragment, id, foundFragment };
};

export const deleteHighlightCurry = (highlight: HighlightNode) => () => {
  doDeleteHighlight(
    {
      input: {
        id: highlight.id,
      },
    },
    {
      update: (
        cache,
        {
          data: {
            deleteHighlight: { success },
          },
        }
      ) => {
        if (success) {
          log.debug('delete successful');
          const page = highlight.page;
          log.debug(page.__typename);
          const { fragment, id, foundFragment } = getFragment(cache, page);
          log.debug(foundFragment, id, fragment);
          if (foundFragment) {
            const data = {
              ...foundFragment,
              highlights: foundFragment.highlights.filter((h) => h.id !== highlight.id),
            };
            cache.writeFragment({
              fragment,
              id,
              data,
            });
          }
        }
      },
    }
  );
};
