/*
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 *
 * Modifications Copyright OpenSearch Contributors. See
 * GitHub history for details.
 */

/*
 * Licensed to Elasticsearch B.V. under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch B.V. licenses this file to you under
 * the Apache License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import React, {
  cloneElement,
  Component,
  HTMLAttributes,
  ReactElement,
  ReactNode,
} from 'react';
import classNames from 'classnames';
import { tabbable } from 'tabbable';

import { CommonProps, NoArgCallback, keysOf } from '../common';
import { OuiIcon } from '../icon';
import { OuiResizeObserver } from '../observer/resize_observer';
import { cascadingMenuKeys } from '../../services';
import {
  OuiContextMenuItem,
  OuiContextMenuItemProps,
} from './context_menu_item';

export type OuiContextMenuPanelHeightChangeHandler = (height: number) => void;
export type OuiContextMenuPanelTransitionType = 'in' | 'out';
export type OuiContextMenuPanelTransitionDirection = 'next' | 'previous';
export type OuiContextMenuPanelShowPanelCallback = (
  currentPanelIndex?: number
) => void;

const titleSizeToClassNameMap = {
  s: 'ouiContextMenuPanelTitle--small',
  m: null,
};

export const SIZES = keysOf(titleSizeToClassNameMap);

export interface OuiContextMenuPanelProps {
  hasFocus?: boolean;
  initialFocusedItemIndex?: number;
  items?: ReactElement[];
  onClose?: NoArgCallback<void>;
  onHeightChange?: OuiContextMenuPanelHeightChangeHandler;
  onTransitionComplete?: NoArgCallback<void>;
  onUseKeyboardToNavigate?: NoArgCallback<void>;
  showNextPanel?: OuiContextMenuPanelShowPanelCallback;
  showPreviousPanel?: NoArgCallback<void>;
  title?: ReactNode;
  transitionDirection?: OuiContextMenuPanelTransitionDirection;
  transitionType?: OuiContextMenuPanelTransitionType;
  watchedItemProps?: string[];
  /**
   * Alters the size of the items and the title
   */
  size?: typeof SIZES[number];
}

type Props = CommonProps &
  Omit<
    HTMLAttributes<HTMLDivElement>,
    'onKeyDown' | 'tabIndex' | 'onAnimationEnd' | 'title'
  > &
  OuiContextMenuPanelProps;

const transitionDirectionAndTypeToClassNameMap = {
  next: {
    in: 'ouiContextMenuPanel-txInLeft',
    out: 'ouiContextMenuPanel-txOutLeft',
  },
  previous: {
    in: 'ouiContextMenuPanel-txInRight',
    out: 'ouiContextMenuPanel-txOutRight',
  },
};

interface State {
  prevProps: {
    items: Props['items'];
  };
  menuItems: HTMLElement[];
  focusedItemIndex?: number;
  currentHeight?: number;
  height?: number;
}

export class OuiContextMenuPanel extends Component<Props, State> {
  static defaultProps: Partial<Props> = {
    hasFocus: true,
    items: [],
  };

  private _isMounted = false;
  private backButton?: HTMLElement | null = null;
  private content?: HTMLElement | null = null;
  private panel?: HTMLElement | null = null;

  constructor(props: Props) {
    super(props);

    this.state = {
      prevProps: {
        items: this.props.items,
      },
      menuItems: [],
      focusedItemIndex: props.initialFocusedItemIndex,
      currentHeight: undefined,
    };
  }

  incrementFocusedItemIndex = (amount: number) => {
    let nextFocusedItemIndex;

    if (this.state.focusedItemIndex === undefined) {
      // If this is the beginning of the user's keyboard navigation of the menu, then we'll focus
      // either the first or last item.
      nextFocusedItemIndex = amount < 0 ? this.state.menuItems.length - 1 : 0;
    } else {
      nextFocusedItemIndex = this.state.focusedItemIndex + amount;

      if (nextFocusedItemIndex < 0) {
        nextFocusedItemIndex = this.state.menuItems.length - 1;
      } else if (nextFocusedItemIndex === this.state.menuItems.length) {
        nextFocusedItemIndex = 0;
      }
    }

    this.setState({
      focusedItemIndex: nextFocusedItemIndex,
    });
  };

  onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    // If this panel contains items you can use the left arrow key to go back at any time.
    // But if it doesn't contain items, then you have to focus on the back button specifically,
    // since there could be content inside the panel which requires use of the left arrow key,
    // e.g. text inputs.
    const { items, showPreviousPanel } = this.props;

    if (
      (items && items.length) ||
      document.activeElement === this.backButton ||
      document.activeElement === this.panel
    ) {
      if (event.key === cascadingMenuKeys.ARROW_LEFT) {
        if (showPreviousPanel) {
          event.preventDefault();
          event.stopPropagation();
          showPreviousPanel();

          if (this.props.onUseKeyboardToNavigate) {
            this.props.onUseKeyboardToNavigate();
          }
        }
      }
    }

    if (this.props.items && this.props.items.length) {
      switch (event.key) {
        case cascadingMenuKeys.TAB:
          // We need to sync up with the user if s/he is tabbing through the items.
          const focusedItemIndex = this.state.menuItems.indexOf(
            document.activeElement as HTMLElement
          );

          this.setState({
            focusedItemIndex:
              focusedItemIndex >= 0 &&
              focusedItemIndex < this.state.menuItems.length
                ? focusedItemIndex
                : undefined,
          });
          break;

        case cascadingMenuKeys.ARROW_UP:
          event.preventDefault();
          this.incrementFocusedItemIndex(-1);

          if (this.props.onUseKeyboardToNavigate) {
            this.props.onUseKeyboardToNavigate();
          }
          break;

        case cascadingMenuKeys.ARROW_DOWN:
          event.preventDefault();
          this.incrementFocusedItemIndex(1);

          if (this.props.onUseKeyboardToNavigate) {
            this.props.onUseKeyboardToNavigate();
          }
          break;

        case cascadingMenuKeys.ARROW_RIGHT:
          if (this.props.showNextPanel) {
            event.preventDefault();
            this.props.showNextPanel(this.state.focusedItemIndex);

            if (this.props.onUseKeyboardToNavigate) {
              this.props.onUseKeyboardToNavigate();
            }
          }
          break;

        default:
          break;
      }
    }
  };

  updateFocus() {
    // Give positioning time to render before focus is applied. Otherwise page jumps.
    requestAnimationFrame(() => {
      if (!this._isMounted) {
        return;
      }

      // If this panel has lost focus, then none of its content should be focused.
      if (!this.props.hasFocus) {
        if (this.panel && this.panel.contains(document.activeElement)) {
          (document.activeElement as HTMLElement).blur();
        }
        return;
      }

      // Setting focus while transitioning causes the animation to glitch, so we have to wait
      // until it's finished before we focus anything.
      if (this.props.transitionType) {
        return;
      }

      // `focusedItemIndex={-1}` specifies that the panel itself should be focused.
      // This should only be used when the panel does not have `item`s
      // and preventing autofocus is desired, which is an uncommon case.
      if (this.panel && this.state.focusedItemIndex === -1) {
        this.panel.focus();
        return;
      }

      // If there aren't any items then this is probably a form or something.
      if (!this.state.menuItems.length) {
        // If we've already focused on something inside the panel, everything's fine.
        if (this.panel && this.panel.contains(document.activeElement)) {
          return;
        }

        // Otherwise let's focus the first tabbable item and expedite input from the user.
        if (this.content) {
          const tabbableItems = tabbable(this.content, {
            displayCheck: 'legacy-full',
          });

          if (tabbableItems.length) {
            tabbableItems[0].focus();
          }
        }
        return;
      }

      // If an item is focused, focus it.
      if (this.state.focusedItemIndex !== undefined) {
        this.state.menuItems[this.state.focusedItemIndex].focus();
        return;
      }

      // Focus on the panel as a last resort.
      if (this.panel && !this.panel.contains(document.activeElement)) {
        this.panel.focus();
      }
    });
  }

  onTransitionComplete = () => {
    if (this.props.onTransitionComplete) {
      this.props.onTransitionComplete();
    }
  };

  componentDidMount() {
    this.updateFocus();
    this._isMounted = true;
  }

  componentWillUnmount() {
    this._isMounted = false;
  }

  static getDerivedStateFromProps(
    nextProps: Props,
    prevState: State
  ): Partial<State> | null {
    let needsUpdate = false;
    const nextState: Partial<State> = {};

    // Clear refs to menuItems if we're getting new ones.
    if (nextProps.items !== prevState.prevProps.items) {
      needsUpdate = true;
      nextState.menuItems = [];
      nextState.prevProps = { items: nextProps.items };
    }

    if (needsUpdate) {
      return nextState;
    }
    return null;
  }

  getWatchedPropsForItems(items: ReactElement[]) {
    // This lets us compare prevProps and nextProps among items so we can re-render if our items
    // have changed.
    const { watchedItemProps } = this.props;

    // Create fingerprint of all item's watched properties
    if (items.length && watchedItemProps && watchedItemProps.length) {
      return JSON.stringify(
        items.map((item) => {
          // Create object of item properties and values
          const props: any = {
            key: item.key,
          };
          watchedItemProps.forEach((prop: string) => {
            props[prop] = item.props[prop];
          });
          return props;
        })
      );
    }

    return null;
  }

  didItemsChange(prevItems: ReactElement[], nextItems: ReactElement[]) {
    // If the count of items has changed then update
    if (prevItems.length !== nextItems.length) {
      return true;
    }

    // Check if any watched item properties changed by quick string comparison
    if (
      this.getWatchedPropsForItems(nextItems) !==
      this.getWatchedPropsForItems(prevItems)
    ) {
      return true;
    }
  }

  shouldComponentUpdate(nextProps: Props, nextState: State) {
    // Prevent calling `this.updateFocus()` below if we don't have to.
    if (nextProps.hasFocus !== this.props.hasFocus) {
      return true;
    }

    if (nextProps.transitionType !== this.props.transitionType) {
      return true;
    }

    if (nextState.focusedItemIndex !== this.state.focusedItemIndex) {
      return true;
    }

    // **
    // this component should have either items or children,
    // if there are items we can determine via `watchedItemProps` if we should update
    // if there are children we can't know if they have changed so return true
    // **

    if (
      (this.props.items && this.props.items.length > 0) ||
      (nextProps.items && nextProps.items.length > 0)
    ) {
      if (this.didItemsChange(this.props.items!, nextProps.items!)) {
        return true;
      }
    }

    // it's not possible (in any good way) to know if `children` has changed, assume they might have
    if (this.props.children != null) {
      return true;
    }

    return false;
  }

  updateHeight() {
    const currentHeight = this.panel ? this.panel.clientHeight : 0;

    if (this.state.height !== currentHeight) {
      if (this.props.onHeightChange) {
        this.props.onHeightChange(currentHeight);

        this.setState({ height: currentHeight });
      }
    }
  }

  componentDidUpdate() {
    this.updateFocus();
  }

  menuItemRef = (index: number, node: HTMLElement | null) => {
    // There's a weird bug where if you navigate to a panel without items, then this callback
    // is still invoked, so we have to do a truthiness check.
    if (node) {
      // Store all menu items.
      this.state.menuItems[index] = node;
    }
  };

  panelRef = (node: HTMLElement | null) => {
    this.panel = node;

    this.updateHeight();
  };

  contentRef = (node: HTMLElement | null) => {
    this.content = node;
  };

  render() {
    const {
      children,
      className,
      onClose,
      title,
      onHeightChange,
      transitionType,
      transitionDirection,
      onTransitionComplete,
      onUseKeyboardToNavigate,
      hasFocus,
      items,
      watchedItemProps,
      initialFocusedItemIndex,
      showNextPanel,
      showPreviousPanel,
      size,
      ...rest
    } = this.props;
    let panelTitle;

    if (title) {
      const titleClasses = classNames(
        'ouiContextMenuPanelTitle',
        size && titleSizeToClassNameMap[size]
      );

      if (Boolean(onClose)) {
        panelTitle = (
          <button
            className={titleClasses}
            type="button"
            onClick={onClose}
            ref={(node) => {
              this.backButton = node;
            }}
            data-test-subj="contextMenuPanelTitleButton">
            <span className="ouiContextMenu__itemLayout">
              <OuiIcon
                type="arrowLeft"
                size="m"
                className="ouiContextMenu__icon"
              />

              <span className="ouiContextMenu__text">{title}</span>
            </span>
          </button>
        );
      } else {
        panelTitle = (
          <div className={titleClasses}>
            <span className="ouiContextMenu__itemLayout">{title}</span>
          </div>
        );
      }
    }

    const classes = classNames(
      'ouiContextMenuPanel',
      className,
      transitionDirection &&
        transitionType &&
        transitionDirectionAndTypeToClassNameMap[transitionDirection]
        ? transitionDirectionAndTypeToClassNameMap[transitionDirection][
            transitionType
          ]
        : undefined
    );

    const content =
      items && items.length
        ? items.map((MenuItem, index) => {
            const cloneProps: Partial<OuiContextMenuItemProps> = {
              buttonRef: (node) => this.menuItemRef(index, node),
            };
            if (size) {
              cloneProps.size = size;
            }
            return MenuItem.type === OuiContextMenuItem
              ? cloneElement(MenuItem, cloneProps)
              : MenuItem;
          })
        : children;

    return (
      <div
        ref={this.panelRef}
        className={classes}
        onKeyDown={this.onKeyDown}
        tabIndex={-1}
        onAnimationEnd={this.onTransitionComplete}
        {...rest}>
        {panelTitle}

        <div ref={this.contentRef}>
          <OuiResizeObserver onResize={() => this.updateHeight()}>
            {(resizeRef) => <div ref={resizeRef}>{content}</div>}
          </OuiResizeObserver>
        </div>
      </div>
    );
  }
}
