/**
 * Copyright Aquera Inc 2023
 *
 * This source code is licensed under the BSD-3-Clause license found in the
 * LICENSE file in the root directory of this source tree.
 */

import {LitElement, html, CSSResultArray, TemplateResult, PropertyValues} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import {styles} from './nile-dropdown.css';
import { animateTo, stopAnimations } from '../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import {  query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../utilities/animation-registry';
import { getTabbableBoundary } from '../internal/tabbable';
import { waitForEvent } from '../internal/event';
import { watch } from '../internal/watch';
import NileElement from '../internal/nile-element';
import type { CSSResultGroup } from 'lit';
import type NileButton from '../nile-button/nile-button';
import type NileIconButton from '../nile-icon-button/nile-icon-button';
import type { NileMenu } from '../nile-menu';
import type { NilePopup } from '../nile-popup';
import '../nile-popup';
import { DropdownPortalManager } from './portal-manager';
import { VisibilityManager } from '../utilities/visibility-manager.js';

/**
 * Nile icon component.
 *
 * @tag nile-dropdown
 * @summary Dropdowns expose additional content that "drops down" in a panel.
 * @dependency nile-popup
 *
 * @slot - The dropdown's main content.
 * @slot trigger - The dropdown's trigger, usually a `<nile-button>` element.
 *
 * @event nile-show - Emitted when the dropdown opens.
 * @event nile-after-show - Emitted after the dropdown opens and all animations are complete.
 * @event nile-hide - Emitted when the dropdown closes.
 * @event nile-after-hide - Emitted after the dropdown closes and all animations are complete.
 *
 * @csspart base - The component's base wrapper.
 * @csspart trigger - The container that wraps the trigger.
 * @csspart panel - The panel that gets shown when the dropdown is open.
 *
 * @animation dropdown.show - The animation to use when showing the dropdown.
 * @animation dropdown.hide - The animation to use when hiding the dropdown.
 */
@customElement('nile-dropdown')
export class NileDropdown extends NileElement {
  static styles: CSSResultGroup = styles;

  @query('.dropdown') popup: NilePopup;
  @query('.dropdown__trigger') trigger: HTMLSlotElement;
  @query('.dropdown__panel') panel: HTMLSlotElement;


  /**
   * Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
   * can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
   */
  @property({ type: Boolean, reflect: true }) open = false;

  /**
   * The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
   * inside of the viewport.
   */
  @property({ reflect: true }) placement:
    | 'top'
    | 'top-start'
    | 'top-end'
    | 'bottom'
    | 'bottom-start'
    | 'bottom-end'
    | 'right'
    | 'right-start'
    | 'right-end'
    | 'left'
    | 'left-start'
    | 'left-end' = 'bottom-start';

  /** Disables the dropdown so the panel will not open. */
  @property({ type: Boolean, reflect: true }) disabled = false;

  /**
   * By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
   * dropdowns that allow for multiple interactions.
   */
  @property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;

  /**
   * The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
   * components that use a dropdown internally.
   */
  @property({ attribute: false }) containingElement?: HTMLElement;

  /** The distance in pixels from which to offset the panel away from its trigger. */
  @property({ type: Number }) distance = 0;


  @property({ type: Boolean }) noOpenOnClick = false;

  /** The distance in pixels from which to offset the panel along its trigger. */
  @property({ type: Number }) skidding = 0;

  /** Syncs the popup's width or height to that of the anchor element. */
  @property() sync: 'width' | 'height' | 'both';

  /**
   * Enable this option to prevent the panel from being clipped when the component is placed inside a container with
   * `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
   */
  @property({ type: Boolean }) hoist = false;

  /**
   * Enable portal mode to render the dropdown panel in a portal outside the component's DOM tree.
   * This provides better positioning control and prevents clipping issues in complex layouts.
   */
  @property({ type: Boolean, reflect: true }) portal = false;

  @property({ type: Boolean, reflect: true }) enableVisibilityEffect = false;

  @property({ type: Boolean, reflect: true }) enableTabClose = false;


  private portalManager: DropdownPortalManager | null = null;
  private visibilityManager?: VisibilityManager;

  connectedCallback() {
    super.connectedCallback();
    this.handlePanelSelect = this.handlePanelSelect.bind(this);
    this.handleKeyDown = this.handleKeyDown.bind(this);
    this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
    this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);

    if (!this.containingElement) {
      this.containingElement = this;
    }
    this.emit('nile-init');

    requestAnimationFrame(() => {
      if (this.portal && !this.portalManager) {
        this.portalManager = new DropdownPortalManager(this);
      }
    });
  }

  protected async firstUpdated(_changed: PropertyValues) {
  
    this.panel.hidden = !this.open;

    // If the dropdown is visible on init, update its position
    if (this.open) {
      this.addOpenListeners();
      this.popup.active = true;
    }
  
    await this.updateComplete;
    const triggerNode = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
  
    this.visibilityManager = new VisibilityManager({
      host: this,
      target: triggerNode || null,
      enableVisibilityEffect: this.enableVisibilityEffect,
      enableTabClose: this.enableTabClose,
      isOpen: () => this.open,
      onAnchorOutOfView: () => {
        this.hide();
        this.emit('nile-visibility-change', {
          visible: false,
          reason: 'anchor-out-of-view',
        });
      },
      onDocumentHidden: () => {
        this.hide();
        this.emit('nile-visibility-change', {
          visible: false,
          reason: 'document-hidden',
        });
      },
      emit: (event, detail) => this.emit(`nile-${event}`, detail),
    });
  }
  

  disconnectedCallback() {
    super.disconnectedCallback();
    this.visibilityManager?.cleanup();
    this.removeOpenListeners();
    this.hide();
    this.emit('nile-destroy');

    if (this.portalManager) {
      this.portalManager.cleanupPortalAppend();
      this.portalManager = null;
    }
  }

  focusOnTrigger() {
    const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
    if (typeof trigger?.focus === 'function') {
      trigger.focus();
    }
  }

  getMenu() {
    return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'nile-menu') as
      | NileMenu
      | undefined;
  }

  handleKeyDown(event: KeyboardEvent) {
    // Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation
    // in case any ancestors are also listening for this key.
    if (this.open && event.key === 'Escape') {
      event.stopPropagation();
      this.hide();
      this.focusOnTrigger();
    }
  }

  handleDocumentKeyDown(event: KeyboardEvent) {
    // Close when escape or tab is pressed
    if (event.key === 'Escape' && this.open) {
      event.stopPropagation();
      this.focusOnTrigger();
      this.hide();
      return;
    }

    // Handle tabbing
    if (event.key === 'Tab') {
      // Tabbing within an open menu should close the dropdown and refocus the trigger
      if (this.open && document.activeElement?.tagName.toLowerCase() === 'nile-menu-item') {
        event.preventDefault();
        this.hide();
        this.focusOnTrigger();
        return;
      }

      // Tabbing outside of the containing element closes the panel
      //
      // If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
      // otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
      setTimeout(() => {
        const activeElement =
          this.containingElement?.getRootNode() instanceof ShadowRoot
            ? document.activeElement?.shadowRoot?.activeElement
            : document.activeElement;

        const hitSelf = this.containingElement && activeElement?.closest(this.containingElement.tagName.toLowerCase()) === this.containingElement;
        const hitPortalAppend = this.portal && this.portalManager?.portalContainerElement && 
                                (activeElement === this.portalManager.portalContainerElement || 
                                 activeElement?.closest('.nile-dropdown-portal-append') === this.portalManager.portalContainerElement);

        if (!hitSelf && !hitPortalAppend) {
          this.hide();
        }
      });
    }
  }

  handleDocumentMouseDown(event: MouseEvent) {
    // Close when clicking outside of the containing element
    const path = event.composedPath();
    const hitSelf = this.containingElement && path.includes(this.containingElement);
    const hitPortalAppend = this.portal && this.portalManager?.portalContainerElement && path.includes(this.portalManager.portalContainerElement);

    if (!hitSelf && !hitPortalAppend) {
      this.hide();
    }
  }

  handlePanelSelect(event: any) {
    const target = event.target as HTMLElement;

    // Hide the dropdown when a menu item is selected
    if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'nile-menu') {
      this.hide();
      this.focusOnTrigger();
    } else if(this.stayOpenOnSelect && target.tagName.toLowerCase() === 'nile-menu') {
      this.show();
      this.focusOnTrigger();
    }
  }

  handleTriggerClick() {
    if(this.noOpenOnClick){
      return;
    }
    if (this.disabled || this.isTriggerDisabled()) {
      return;
    }
    if (this.open) {
      this.hide();
    } else {
      this.show();
      this.focusOnTrigger();
    }
  }

  handleTriggerKeyDown(event: KeyboardEvent) {
    // When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
    // key again to hide the menu in case they don't want to make a selection.
    // if (['Enter'].includes(event.key)) {
    //   event.preventDefault();
    //   this.handleTriggerClick();
    //   return;
    // }

    const menu = this.getMenu();

    if (menu) {
      const menuItems = menu.getAllItems();
      const firstMenuItem = menuItems[0];
      const lastMenuItem = menuItems[menuItems.length - 1];

      // When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
      // selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
      // faster navigation.
      if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
        event.preventDefault();

        // Show the menu if it's not already open
        if (!this.open) {
          this.show();
        }

        if (menuItems.length > 0) {
          // Focus on the first/last menu item after showing
          this.updateComplete.then(() => {
            if (event.key === 'ArrowDown' || event.key === 'Home') {
              menu.setCurrentItem(firstMenuItem);
              firstMenuItem.focus();
            }

            if (event.key === 'ArrowUp' || event.key === 'End') {
              menu.setCurrentItem(lastMenuItem);
              lastMenuItem.focus();
            }
          });
        }
      }
    }
  }

  handleTriggerKeyUp(event: KeyboardEvent) {
    // Prevent space from triggering a click event in Firefox
    if (event.key === ' ') {
      event.preventDefault();
    }
  }

  handleTriggerSlotChange() {
    this.updateAccessibleTrigger();
  }

  private isTriggerDisabled(): boolean {
    const trigger = this.querySelector('[slot="trigger"]') as any;
    return trigger?.hasAttribute?.('disabled');
  }

  //
  // Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
  // `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
  // that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
  // a child of the slotted element, or an element in the slotted element's shadow root.
  //
  // For example, the accessible trigger of an <nile-button> is a <button> located inside its shadow root.
  //
  // To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
  //
  updateAccessibleTrigger() {
    const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
    const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
    let target: HTMLElement;

    if (accessibleTrigger) {
      switch (accessibleTrigger.tagName.toLowerCase()) {
        // nile buttons have to update the internal button so it's announced correctly by screen readers
        case 'nile-button':
        case 'nile-icon-button':
          target = (accessibleTrigger as NileButton | NileIconButton).button;
          break;

        default:
          target = accessibleTrigger;
      }

      target.setAttribute('aria-haspopup', 'true');
      target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
    }
  }

  /** Shows the dropdown panel. */
  async show() {
    if (this.open || this.disabled || this.isTriggerDisabled()) {
      return undefined;
    }

    this.open = true;
    return waitForEvent(this, 'nile-after-show');
  }

  /** Hides the dropdown panel */
  async hide() {
    if (!this.open) {
      return undefined;
    }

    this.open = false;
    return waitForEvent(this, 'nile-after-hide');
  }

  /**
   * Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
   * is activated.
   */
  reposition() {
    this.popup.reposition();
  }

  addOpenListeners() {
    this.panel.addEventListener('nile-select', this.handlePanelSelect);
    this.panel.addEventListener('keydown', this.handleKeyDown);
    document.addEventListener('keydown', this.handleDocumentKeyDown);
    document.addEventListener('mousedown', this.handleDocumentMouseDown);
  }

  removeOpenListeners() {
    if (this.panel) {
      this.panel.removeEventListener('nile-select', this.handlePanelSelect);
      this.panel.removeEventListener('keydown', this.handleKeyDown);
    }
    document.removeEventListener('keydown', this.handleDocumentKeyDown);
    document.removeEventListener('mousedown', this.handleDocumentMouseDown);
  }

  private _triggerDisabledByDropdown = false;

  protected updated(changedProperties: PropertyValues): void {
    super.updated(changedProperties);
    
    if (changedProperties.has('portal')) {
      if (this.portal && !this.portalManager) {
        this.portalManager = new DropdownPortalManager(this);
        if (this.open) {
          this.portalManager.setupPortalAppend();
        }
      } else if (!this.portal && this.portalManager) {
        this.portalManager.cleanupPortalAppend();
        this.portalManager = null;
      }
    }
    if (changedProperties.has('disabled')) {
      if (this.disabled && this.open) {
        void this.hide();
      }
      
      this.updateTriggerDisabledState();
    }
  }
  
  private updateTriggerDisabledState() {
    const trigger = this.querySelector('[slot="trigger"]') as any;
    if (!trigger) return;
    if (this.disabled){
      if (!trigger.disabled) {
        this._triggerDisabledByDropdown = true;
      }
      trigger.disabled = true;
    } else if (this._triggerDisabledByDropdown) {
      trigger.disabled = false;
      this._triggerDisabledByDropdown = false;
    }
  }

  @watch('open', { waitUntilFirstUpdate: true })
  async handleOpenChange() {
    this.visibilityManager?.setup();
    if ( this.open && (this.disabled || this.isTriggerDisabled()) ) {
      this.open = false;
      return;
    }

    this.updateAccessibleTrigger();

    if (this.open) {
      // Show
      this.emit('nile-show');
      this.addOpenListeners();

      if (this.portal && this.portalManager) {
        this.portalManager.setupPortalAppend();
      } else if (this.portal && !this.portalManager) {
        this.portalManager = new DropdownPortalManager(this);
        this.portalManager.setupPortalAppend();
      }

      await stopAnimations(this);
      
      // Only show panel and animate if not using portal
      if (!this.portal) {
        this.panel.hidden = false;
        this.popup.active = true;
        const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: '' });
        await animateTo(this.popup.popup, keyframes, options);
      }

      this.emit('nile-after-show');
    } else {
      // Hide
      this.emit('nile-hide');
      this.removeOpenListeners();
      this.visibilityManager?.cleanup();

      if (this.portal && this.portalManager) {
        this.portalManager.cleanupPortalAppend();
      }

      await stopAnimations(this);
      
      // Only hide panel and animate if not using portal
      if (!this.portal) {
        const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: '' });
        await animateTo(this.popup.popup, keyframes, options);
        this.panel.hidden = true;
        this.popup.active = false;
      }

      this.emit('nile-after-hide');
    }
  }

  render() {
    return html`
      <nile-popup
        part="base"
        id="dropdown"
        placement=${this.placement}
        distance=${this.distance}
        skidding=${this.skidding}
        strategy=${this.hoist ? 'fixed' : 'absolute'}
        sync=${this.sync}
        flip
        shift
        auto-size="both"
        auto-size-padding="10"
        class=${classMap({
          dropdown: true,
          'dropdown--open': this.open
        })}
      >
        <slot
          name="trigger"
          slot="anchor"
          part="trigger"
          class="dropdown__trigger"
          @click=${this.handleTriggerClick}
          @keydown=${this.handleTriggerKeyDown}
          @keyup=${this.handleTriggerKeyUp}
          @slotchange=${this.handleTriggerSlotChange}
        ></slot>

        <slot
          part="panel"
          class="dropdown__panel"
          aria-hidden=${this.open ? 'false' : 'true'}
          aria-labelledby="dropdown"
          style=${this.portal && this.portalManager ? 'display: none;' : ''}
        ></slot>
      </nile-popup>
    `;
  }
}

setDefaultAnimation('dropdown.show', {
  keyframes: [
    { opacity: 0, scale: 0.9 },
    { opacity: 1, scale: 1 }
  ],
  options: { duration: 100, easing: 'ease' }
});

setDefaultAnimation('dropdown.hide', {
  keyframes: [
    { opacity: 1, scale: 1 },
    { opacity: 0, scale: 0.9 }
  ],
  options: { duration: 100, easing: 'ease' }
});

export default NileDropdown;

declare global {
  interface HTMLElementTagNameMap {
    'nile-dropdown': NileDropdown;
  }
}
