/* eslint-disable class-methods-use-this */
import { MenuItem } from './MenuItem.js';
import { genId } from './IdGenerator.js';
import { ICommandBase, IContextMenuCommand, IContextMenuExecuteDetail, ICustomMenuEventDetail, IMenuOptions, iPoint, ITriggerInfo, ObjectLikeMap } from './types.js';
import ContextMenuElement from './ContextualMenuElement.js';
import '../define/contextual-menu/contextual-menu.js';

function cancelEvent(e: Event): void {
  e.preventDefault();
  e.stopPropagation();
}

/**
 * The base class for the context menu. It can be used as is but in some specific cases
 * you may want to extend this class to be able to find command target accurately.
 * 
 * @template S - The definition of the store passed to commands.
 */
export class ContextualMenu extends EventTarget {
  #connectedValue = false;

  /**
   * @returns true when the menu is connected to the `workspace`.
   */
  get connected(): boolean {
    return this.#connectedValue;
  }

  /** 
   * The store initialized with this context menu. Passed to the lifecycle functions.
   */
  store = new Map<string, unknown>();

  /** 
   * The root level menu commands
   */
  commands: MenuItem[] = [];

  /**
   * An information about the trigger that initialized this menu.
   */
  triggerInfo?: ITriggerInfo;

  /**
   * The Menu configuration options.
   */
  options: IMenuOptions;

  currentMenu?: ContextMenuElement;

  /**
   * @param workspace The root element that is the click handler
   * @param opts The Menu configuration options.
   */
  constructor(public workspace: HTMLElement, opts: IMenuOptions = {}) {
    super();
    
    this.options = { ...opts };

    this.contextHandler = this.contextHandler.bind(this);
    this.clickHandler = this.clickHandler.bind(this);
    this.keydownHandler = this.keydownHandler.bind(this);
    this.customMenuHandler = this.customMenuHandler.bind(this);
    this.menuTriggerHandler = this.menuTriggerHandler.bind(this);
  }

  /**
   * Starts listening on user events
   */
  connect(): void {
    this.workspace.addEventListener('contextmenu', this.contextHandler);
    this.workspace.addEventListener('custommenu', this.customMenuHandler as EventListener);
    window.addEventListener('click', this.clickHandler);
    window.addEventListener('keydown', this.keydownHandler);
    this.#connectedValue = true;
  }

  /**
   * Cleans up the listeners
   */
  disconnect(): void {
    this.workspace.removeEventListener('contextmenu', this.contextHandler);
    this.workspace.removeEventListener('custommenu', this.customMenuHandler as EventListener);
    window.removeEventListener('click', this.clickHandler);
    window.removeEventListener('keydown', this.keydownHandler);
    this.#connectedValue = false;
  }

  /**
   * Reads {x,y} point of the click from the pointer event.
   * 
   * Override this method to customize position reading.
   */
  readTargetClickPosition(e: MouseEvent): iPoint {
    return {
      x: e.clientX,
      y: e.clientY,
    }
  }

  /**
   * Override this method to customize finding event click target.
   * @param e Finds the click target from the event
   */
  findTarget(e: MouseEvent): HTMLElement | SVGElement | undefined {
    return e.target as HTMLElement | SVGElement | undefined;
  }

  /**
   * Maps an element to the target name used by the commands.
   * By default it returns `root` when the click target element is the `workspace` or
   * a combination of element name and list of all classes otherwise.
   * 
   * Override this method to customize target name reading.
   *
   * @param element The context click target
   * @returns The target name.
   */
  elementToTarget(element: HTMLElement | SVGElement): string | undefined {
    if (element === this.workspace) {
      return 'root';
    }
    if (!element.localName) {
      return undefined;
    }
    let name = element.localName;
    const cls = Array.from(element.classList).join('.');
    if (cls) {
      name += `.${cls}`;
    }
    return name;
  }

  /**
   * Handler for the context menu event.
   */
  protected contextHandler(e: MouseEvent): void {
    // if (!this.options.cancelNativeWhenHandled) {
    //   cancelEvent(e);
    // }
    this.destroy();
    const target = this.findTarget(e);
    if (!target) {
      return;
    }
    const name = this.elementToTarget(target);
    if (!name) {
      return;
    }
    // since the context menu has fixed position it doesn't matter what the context 
    // is as the menu is rendered over all elements.
    // The `readTargetClickPosition()` is used to determine click target for the commands.
    const clickPoint = {
      x: e.clientX,
      y: e.clientY,
    }
    const targetPoint = this.readTargetClickPosition(e);
    const rendered = this.build(target, name, clickPoint, targetPoint);
    if (rendered) {
      cancelEvent(e);
    } else if (this.options.cancelNativeWhenHandled) {
      cancelEvent(e);
    }
  }

  /**
   * A handler for the `custommenu` event handler to trigger the menu without the user interaction.
   */
  protected customMenuHandler(e: CustomEvent<ICustomMenuEventDetail>): void {
    cancelEvent(e);
    const { name, x = 0, y = 0, actionTarget = this.workspace, clickEvent, customData } = e.detail;
    const clickPoint = {
      x,
      y,
    };
    const targetPoint = clickEvent ? this.readTargetClickPosition(clickEvent) : clickPoint;
    this.build(actionTarget, name, clickPoint, targetPoint, customData);
  }

  /**
   * Handles the click event on the document to close the menu is the click
   * is outside the menu.
   * @param {MouseEvent} e
   */
  protected clickHandler(e: MouseEvent): void {
    if (!this.currentMenu || e.defaultPrevented) {
      return;
    }
    const elm = e.target as Element;
    const inside = this.currentMenu.contains(elm);
    if (!inside) {
      this.destroy();
    }
  }

  /**
   * Closes the menu when ESC is pressed
   */
  protected keydownHandler(e: KeyboardEvent): void {
    if (e.key === 'Escape' && this.currentMenu) {
      this.destroy();
    }
  }

  /**
   * Register a list of commands to be rendered in the menu when triggered.
   * This overrides previously registered commands.
   */
  registerCommands(commands: ICommandBase[]): void {
    if (!Array.isArray(commands) || !commands.length) {
      return;
    }
    this.commands = this.prepareCommands(commands);
  }

  /**
   * Adds a single command to the menu.
   */
  addCommand(command: ICommandBase): void {
    if (!this.commands) {
      this.commands = [];
    }
    const item = this.prepareCommands([command])[0];
    this.commands.push(item);
  }

  /**
   * Prepares incoming commands for the internal use.
   * @param commands - The commands to process.
   */
  protected prepareCommands(commands: ICommandBase[]): MenuItem[] {
    const result: MenuItem[] = [];
    commands.forEach((item) => {
      if (!item) {
        return;
      }
      const { type = 'normal' } = item;
      switch (type) {
        case 'separator': result.push(new MenuItem(item)); break;
        case 'label': result.push(new MenuItem(item)); break;
        case 'normal':
        case 'radio':
          {
            const normalized = this.normalizeMenuItem(item as IContextMenuCommand);
            result.push(new MenuItem(normalized));
          }
          break;
        default:
      }
    });
    return result;
  }

  protected normalizeMenuItem(item: IContextMenuCommand): IContextMenuCommand {
    const cp = { ...item };
    if (!cp.id) {
      cp.id = genId();
    }
    if (Array.isArray(cp.children) && cp.children.length) {
      cp.children = this.prepareCommands(cp.children);
    } else if (cp.children) {
      cp.children = undefined;
    }
    return cp;
  }

  /**
   * Builds the menu for the target.
   *
   * @param target The element that triggered the menu
   * @param name The `target` name declared in the commands.
   * @param placementPoint The workspace position of where to place the menu.
   * @param targetPoint The workspace position of the click target
   * @param customData Any data to set on the instance to pass to the `execute` and `enabled` functions.
   */
  build(target: HTMLElement | SVGElement, name: string, placementPoint: iPoint, targetPoint: iPoint, customData?: unknown): boolean {
    this.triggerInfo = undefined;
    const commands = this.listCommands(name, target);
    if (!commands.length) {
      return false;
    }
    this.triggerInfo = {
      point: targetPoint,
      customData,
      target,
    };
    this.render(placementPoint, commands);
    target.setAttribute('active', '');
    return true;
  }

  /**
   * @param placementPoint The workspace position of where to place the menu.
   * @param commands The commands render
   */
  render(placementPoint: iPoint, commands: MenuItem[]): void {
    const { workspace } = this;
    const menu = document.createElement('contextual-menu');
    menu.store = this.store;
    menu.target = this.triggerInfo?.target;
    menu.customData = this.triggerInfo?.customData;
    menu.workspace = workspace;
    menu.classList.add('context-menu');
    menu.commands = commands;
    menu.style.top = `${placementPoint.y}px`;
    menu.style.left = `${placementPoint.x}px`;
    menu.setAttribute('tabindex', '0');

    if (workspace.shadowRoot) {
      workspace.shadowRoot.append(menu);
    } else {
      this.workspace.append(menu);
    }
    this.currentMenu = menu;
    menu.focus();
    menu.addEventListener('trigger', this.menuTriggerHandler as EventListener);
  }

  /**
   * Lists all commands that matches the target.
   *
   * @param targetName The build target name
   * @param targetElement The element that triggered the menu
   */
  listCommands(targetName: string, targetElement: HTMLElement | SVGElement): MenuItem[] {
    const { commands = [] } = this;
    const result: MenuItem[] = [];
    const filter = ['all', targetName];
    commands.forEach((cmd) => {
      if (Array.isArray(cmd.target)) {
        const hasTarget = cmd.target.some((item) => filter.includes(item));
        if (hasTarget) {
          result.push(cmd);
        }
      } else if (filter.includes(cmd.target || '')) {
        result.push(cmd);
      } else {
        try {
          if (targetElement.matches(cmd.target || '')) {
            result.push(cmd);
          }
        } catch (e) {
          // 
        }
      }
    });
    return result;
  }

  /**
   * Removes the currently rendered menu.
   */
  destroy(): void {
    if (!this.currentMenu) {
      return;
    }
    (this.triggerInfo as ITriggerInfo).target.removeAttribute('active');
    this.triggerInfo = undefined;
    const { workspace } = this;
    if (workspace.shadowRoot) {
      workspace.shadowRoot.removeChild(this.currentMenu);
    } else {
      workspace.removeChild(this.currentMenu);
    }
    this.currentMenu.removeEventListener('trigger', this.menuTriggerHandler as EventListener);
    this.currentMenu = undefined;
  }

  /**
   * Handler for the `trigger` event dispatched by the menu
   */
  protected menuTriggerHandler(e: CustomEvent): void {
    const { item, command, parent } = e.detail;
    const cmd = command as MenuItem;
    const parentCommand = parent as MenuItem;
    const { triggerInfo, workspace, store } = this;
    const { point, target, customData } = triggerInfo as ITriggerInfo;
    this.destroy();
    if (parentCommand && parentCommand.execute && !cmd.execute) {
      parentCommand.trigger(cmd, store as unknown as ObjectLikeMap<object>, target, workspace, point, customData, item);
    } else if (cmd.execute) {
      cmd.trigger(cmd, store as unknown as ObjectLikeMap<object>, target, workspace, point, customData);
    } else {
      const detail: IContextMenuExecuteDetail = {
        id: cmd.id,
        store: store as unknown as ObjectLikeMap<object>,
        target,
        root: workspace,
        clickPoint: point,
        customData,
        selectedSubcommand: item,
        item: cmd,
      };
      this.dispatchEvent(new CustomEvent('execute', {
        detail,
      }));
    }
  }
}
