import { ReactElement, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { MouseEventTypes } from './mouseEventTypes';
import { MutationHelper } from './mutationHelper';
import { NodeSelector } from './nodeSelector';

/**
 * The `ElementObserver` class monitors all changes related to the specific element it is responsible for.
 * It is automatically instantiated by the `useElementObserver` hook, which manages all instances of this class.
 *
 * How it works:
 *
 * Each instance of `ElementObserver` holds several pieces of information related to the node it is monitoring,
 * such as the node's selector and its `MutationObserver`. This class is responsible for translating raw node
 * changes into a more user-friendly format. For example, when the node is added to the DOM, the `ElementObserver`
 * is notified by the `DOMObserver`, which triggers the `onAdd` callback function on the respective `useElementObserver`
 * instance.
 *
 * Instances of `ElementObserver` are fully managed by the `useElementObserver` hook, requiring no additional configuration.
 * When the hook is destroyed, its corresponding `ElementObserver` instance is automatically removed.
 *
 */
export class ElementObserver {
  private mutation: MutationObserver | null;

  private node: HTMLElement | null;

  private selector: NodeSelector;

  private eventListeners: Map<keyof HTMLElementEventMap, EventListenerOrEventListenerObject>;

  private classAdded: Map<string, () => void>;

  private classRemoved: Map<string, () => void>;

  private nodeDisplay: string;

  private addFn: (el: HTMLElement | null) => void;

  private removeFn: (el: HTMLElement | null) => void;

  private changeFn: (el: HTMLElement | null) => void;

  private classChangeFn: (classes?: DOMTokenList) => void;

  private addChildFn?: () => void;

  private removeChildFn?: () => void;

  private resetFn: () => void;

  constructor(selector: NodeSelector) {
    this.selector = selector;
    this.mutation = null;
    this.node = null;
    this.eventListeners = new Map();
    this.classAdded = new Map();
    this.classRemoved = new Map();
    this.nodeDisplay = 'block';
    this.addFn = () => {};
    this.removeFn = () => {};
    this.changeFn = () => {};
    this.classChangeFn = () => {};
    this.addChildFn = undefined;
    this.removeChildFn = undefined;
    this.resetFn = () => {};
  }

  private handleAttributes(mutationRecord: MutationRecord[]) {
    mutationRecord.filter(MutationHelper.isClass).forEach(mutation => {
      MutationHelper.fireClassChange(mutation, this.node, this.classChangeFn);
      MutationHelper.fireClassAdded(mutation, this.node, this.classAdded);
      MutationHelper.fireClassRemoved(mutation, this.node, this.classRemoved);
    });
  }

  private handleChildList(mutationRecord: MutationRecord[]) {
    if (!this.addChildFn && !this.removeChildFn) {
      return;
    }

    mutationRecord.filter(MutationHelper.isChildList).forEach(mutation => {
      MutationHelper.fireSubtreeChange(mutation.addedNodes, this.addChildFn);
      MutationHelper.fireSubtreeChange(mutation.removedNodes, this.removeChildFn);
    });
  }

  private addInitialListeners() {
    this.eventListeners.forEach((v, k) => this.node?.addEventListener(k, v));
  }

  private clearEventListeners() {
    this.eventListeners.forEach((v, k) => this.node?.removeEventListener(k, v));
    this.eventListeners.clear();
  }

  private renew(newNode: Node | null) {
    if (newNode) {
      this.destroy();
      this.watch(newNode);
    }
  }

  getSelector() {
    return this.selector;
  }

  getNode() {
    return this.node;
  }

  synchronize() {
    const parsedNode = this.getSelector().parse();

    if (this.getNode() !== parsedNode) {
      this.renew(parsedNode);
      return;
    }

    if (!this.getNode() && parsedNode) {
      this.watch(parsedNode);
      return;
    }

    if (this.getNode() && !parsedNode) {
      this.destroy();
    }
  }

  init() {
    const node = this.getSelector().parse();
    if (node) {
      this.watch(node);
    }
  }

  watch(node: Node) {
    if (!this.mutation) {
      this.mutation = new MutationObserver(mutationRecord => {
        this.handleAttributes(mutationRecord);
        this.handleChildList(mutationRecord);
        this.changeFn(this.node);
      });
      this.mutation.observe(node, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeOldValue: true,
      });
      this.node = node as HTMLElement;
      this.addFn(this.node);
      this.addInitialListeners();
    }
  }

  destroy() {
    this.resetFn();
    this.removeFn(this.node);
    this.mutation?.disconnect();
    this.clearEventListeners();
    this.classAdded.clear();
    this.classRemoved.clear();
    this.mutation = null;
    this.node = null;
  }

  render(fn: () => ReactElement) {
    if (this.node) {
      return createPortal(fn() as ReactNode, this.node);
    }
    return undefined;
  }

  fire(event: MouseEventTypes) {
    if (this.node) {
      this.node.dispatchEvent(
        new MouseEvent(event, {
          bubbles: true,
          cancelable: true,
          view: window,
        }),
      );
    }
  }

  show() {
    const display = this.node?.style.display;
    if (this.node && display === 'none') {
      this.node.style.display = this.nodeDisplay || 'block';
    }
  }

  hide() {
    const display = this.node?.style.display;
    if (this.node && display !== 'none') {
      this.nodeDisplay = display || 'block';
      this.node.style.display = 'none';
    }
  }

  registerEvent(event: keyof HTMLElementEventMap, fn: EventListenerOrEventListenerObject) {
    if (this.node) {
      const previousFn = this.eventListeners.get(event);
      if (previousFn) {
        this.node.removeEventListener(event, previousFn);
      }
      this.node.addEventListener(event, fn);
    }
    this.eventListeners.set(event, fn);
  }

  registerOnReset(fn: () => void) {
    this.resetFn = fn;
  }

  registerOnChange(fn: (el: HTMLElement | null) => void) {
    this.changeFn = fn;
  }

  registerOnAdd(fn: (el: HTMLElement | null) => void) {
    this.addFn = fn;
  }

  registerOnRemove(fn: (el: HTMLElement | null) => void) {
    this.removeFn = fn;
  }

  registerOnclassChange(fn: (classes?: DOMTokenList) => void) {
    this.classChangeFn = fn;
  }

  registerOnAddChild(fn: (classes?: DOMTokenList) => void) {
    this.addChildFn = fn;
  }

  registerOnRemoveChild(fn: (classes?: DOMTokenList) => void) {
    this.removeChildFn = fn;
  }

  registerOnClassAdded(className: string, fn: () => void) {
    this.classAdded.set(className, fn);
  }

  registerOnClassRemoved(className: string, fn: () => void) {
    this.classRemoved.set(className, fn);
  }
}
