import { MenuItem } from './internals/MenuItem.js'
import { genId } from './internals/IdGenerator.js'
import type {
  CommandBase,
  ContextMenuCommand,
  ContextMenuExecuteDetail,
  CustomMenuEventDetail,
  MenuContext,
  MenuOptions,
  MenuPoint,
  MenuTriggerInfo,
} from './internals/types.js'
import type ContextMenuElement from './internals/ContextualMenuElement.js'
import { Logger, ILogObj } from 'tslog'
export type * from './internals/types.js'

import './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<S = unknown> 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: S = {} as S

  /**
   * The root level menu commands
   */
  commands: MenuItem<S>[] = []

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

  /**
   * The Menu configuration options.
   */
  options: MenuOptions

  currentMenu?: ContextMenuElement<S>

  #debug = false

  get debug(): boolean {
    return this.#debug
  }

  set debug(value: boolean) {
    if (this.#debug === value) {
      return
    }
    this.#debug = value
    if (this.logger) {
      this.logger.settings.minLevel = value ? 0 : 4
    }
  }

  protected logger: Logger<ILogObj>

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

    this.options = { ...opts }

    this.logger = new Logger({ minLevel: this.debug ? 0 : 4, name: 'context-menu', hideLogPositionForProduction: true })

    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.logger.debug('Connecting contextual menu...')
    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.logger.debug('Disconnecting contextual menu...')
    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): MenuPoint {
    const result: MenuPoint = {
      x: e.clientX,
      y: e.clientY,
    }
    this.logger.debug('readTargetClickPosition result:', result)
    return result
  }

  /**
   * Override this method to customize finding event click target.
   * @param e Finds the click target from the event
   */
  findTarget(e: MouseEvent): HTMLElement | SVGElement | undefined {
    this.logger.debug('findTarget results with "e.target"')
    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) {
      this.logger.debug('elementToTarget result:', 'root')
      return 'root'
    }
    if (!element.localName) {
      this.logger.debug('elementToTarget result:', undefined)
      return undefined
    }
    let name = element.localName
    const cls = Array.from(element.classList).join('.')
    if (cls) {
      name += `.${cls}`
    }
    this.logger.debug('elementToTarget result:', name)
    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)
    this.logger.debug('contextHandler', 'target:', target.localName, 'name:', name)
    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<CustomMenuEventDetail>): 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.
   */
  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.logger.debug('Click outside the menu. Destroing...')
      this.destroy()
    }
  }

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

  /**
   * Register a list of commands to be rendered in the menu when triggered.
   * This overrides previously registered commands.
   */
  registerCommands(commands: (CommandBase<S> | ContextMenuCommand<S>)[]): void {
    if (!Array.isArray(commands) || !commands.length) {
      this.logger.warn('registerCommands called with empty array')
      return
    }
    this.commands = this.prepareCommands(commands)
  }

  /**
   * Adds a single command to the menu.
   */
  addCommand(command: CommandBase<S> | ContextMenuCommand<S>): 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: CommandBase<S>[]): MenuItem<S>[] {
    const result: MenuItem<S>[] = []
    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 ContextMenuCommand<S>)
            result.push(new MenuItem(normalized))
          }
          break
        default:
      }
    })
    return result
  }

  protected normalizeMenuItem(item: ContextMenuCommand<S>): ContextMenuCommand<S> {
    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: MenuPoint,
    targetPoint: MenuPoint,
    customData?: unknown
  ): boolean {
    this.triggerInfo = undefined
    const commands = this.listCommands(name, target)
    this.logger.debug('build', 'commands discovered', commands.length)
    if (!commands.length) {
      return false
    }
    this.triggerInfo = {
      point: targetPoint,
      customData,
      target,
    }
    this.beforeRender()
    this.render(placementPoint, commands)
    target.setAttribute('active', '')
    return true
  }

  /**
   * A lifecycle method called before the `render()` method.
   */
  protected beforeRender(): void {
    const { beforeRender } = this.options
    if (!beforeRender) {
      return
    }
    const context: MenuContext = {
      store: this.store,
      target: this.triggerInfo?.target || this.workspace,
      root: this.workspace,
      customData: this.triggerInfo?.customData,
    }
    try {
      beforeRender(context)
    } catch {
      // ...
    }
  }

  /**
   * @param placementPoint The workspace position of where to place the menu.
   * @param commands The commands render
   */
  render(placementPoint: MenuPoint, commands: MenuItem<S>[]): void {
    const { workspace } = this
    const menu = document.createElement('contextual-menu') as unknown as ContextMenuElement<S>
    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<S>[] {
    this.logger.debug('listCommands')
    const { commands = [] } = this
    const result: MenuItem<S>[] = []
    const filter = ['all', targetName]
    this.logger.debug('listCommands', 'filter:', filter)
    commands.forEach((cmd) => {
      this.logger.silly('listCommands', 'matching tharget', cmd.target)
      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 {
          //
        }
      }
    })
    return result
  }

  /**
   * Removes the currently rendered menu.
   */
  destroy(): void {
    if (!this.currentMenu) {
      return
    }
    const trigger = { ...this.triggerInfo } as MenuTriggerInfo
    ;(this.triggerInfo as MenuTriggerInfo).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
    this.afterClose(trigger)
  }

  protected afterClose(trigger: MenuTriggerInfo): void {
    const { afterClose } = this.options
    if (!afterClose) {
      return
    }
    const context: MenuContext = {
      store: this.store,
      target: trigger.target || this.workspace,
      root: this.workspace,
      customData: trigger.customData,
    }
    try {
      afterClose(context)
    } catch {
      // ...
    }
  }

  /**
   * 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 MenuTriggerInfo
    this.destroy()
    if (parentCommand && parentCommand.execute && !cmd.execute) {
      parentCommand.trigger(cmd, store, target, workspace, point, customData, item)
    } else if (cmd.execute) {
      cmd.trigger(cmd, store, target, workspace, point, customData)
    } else {
      const detail: ContextMenuExecuteDetail = {
        id: cmd.id,
        store,
        target,
        root: workspace,
        clickPoint: point,
        customData,
        selectedSubcommand: item,
        item: cmd,
      }
      this.dispatchEvent(
        new CustomEvent('execute', {
          detail,
        })
      )
    }
  }
}
