// Copyright (C) 2019 The Android Open Source Project
//
// Licensed 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 './modal.scss';
import m from 'mithril';
import {defer} from '../base/deferred';
import {Icon} from './icon';
import {Button, ButtonVariant} from './button';
import {Intent} from './common';

// This module deals with modal dialogs. Unlike most components, here we want to
// render the DOM elements outside of the corresponding vdom tree. For instance
// we might want to instantiate a modal dialog all the way down from a nested
// Mithril sub-component, but we want the result dom element to be nested under
// the root <body>.

// Usage:
// Full-screen modal use cases (the most common case)
// --------------------------------------------------
// - app.ts calls maybeRenderFullscreenModalDialog() when rendering the
//   top-level vdom, if a modal dialog is created via showModal()
// - The user (any TS code anywhere) calls showModal()
// - showModal() takes either:
//   - A static set of mithril vnodes (for cases when the contents of the modal
//     dialog is static and never changes)
//   - A function, invoked on each render pass, that returns mithril vnodes upon
//     each invocation.
//   - See examples in widgets_page.ts for both.
//
// Nested modal use-cases
// ----------------------
// A modal dialog can be created in a "positioned" layer (e.g., any div that has
// position:relative|absolute), so it's modal but only within the scope of that
// layer.
// In this case, just ust the Modal class as a standard mithril component.
// showModal()/closeModal() are irrelevant in this case.

export interface ModalAttrs {
  title: string;
  icon?: string;
  className?: string;
  buttons?: ModalButton[];
  vAlign?: 'MIDDLE' /* default */ | 'TOP';

  // Used to disambiguate between different modal dialogs that might overlap
  // due to different client showing modal dialogs at the same time. This needs
  // to match the key passed to closeModal() (if non-undefined). If the key is
  // not provided, showModal will make up a random key in the showModal() call.
  key?: string;

  // A callback that is called when the dialog is closed, whether by pressing
  // any buttons or hitting ESC or clicking outside of the modal.
  onClose?: () => void;

  // The content/body of the modal dialog. This can be either:
  // 1. A static set of children, for simple dialogs which content never change.
  // 2. A factory method that returns a m() vnode for dyamic content.
  content?: m.Children | (() => m.Children);
}

export interface ModalButton {
  text: string;
  primary?: boolean;
  id?: string;
  // Can be a static boolean or a function for dynamic evaluation on each render
  disabled?: boolean | (() => boolean);
  action?: () => void;
}

// Usually users don't need to care about this class, as this is instantiated
// by showModal. The only case when users should depend on this is when they
// want to nest a modal dialog in a <div> they control (i.e. when the modal
// is scoped to a mithril component, not fullscreen).
export class Modal implements m.ClassComponent<ModalAttrs> {
  onbeforeremove(vnode: m.VnodeDOM<ModalAttrs>) {
    const removePromise = defer<void>();
    vnode.dom.addEventListener('animationend', () => {
      m.redraw();
      removePromise.resolve();
    });
    vnode.dom.classList.add('modal-fadeout');

    // Retuning `removePromise` will cause Mithril to defer the actual component
    // removal until the fade-out animation is done. onremove() will be invoked
    // after this.
    return removePromise;
  }

  onremove(vnode: m.VnodeDOM<ModalAttrs>) {
    if (vnode.attrs.onClose !== undefined) {
      // The onClose here is the promise wrapper created by showModal(), which
      // in turn will: (1) call the user's original attrs.onClose; (2) resolve
      // the promise returned by showModal().
      vnode.attrs.onClose();
    }
  }

  oncreate(vnode: m.VnodeDOM<ModalAttrs>) {
    if (vnode.dom instanceof HTMLElement) {
      // Focus the newly created dialog, so that we react to Escape keydown
      // even if the user has not clicked yet on any element.
      // If there is a primary button, focus that, so Enter does the default
      // action. If not just focus the whole dialog.
      const primaryBtn = vnode.dom.querySelector(
        '.pf-button.pf-intent-primary',
      );
      if (primaryBtn) {
        (primaryBtn as HTMLElement).focus();
      } else {
        vnode.dom.focus();
      }
      // If the modal dialog is instantiated in a tall scrollable container,
      // make sure to scroll it into the view.
      vnode.dom.scrollIntoView({block: 'center'});
    }
  }

  view(vnode: m.Vnode<ModalAttrs>) {
    const attrs = vnode.attrs;

    const buttons: m.Children = [];
    for (const button of attrs.buttons || []) {
      const isDisabled =
        typeof button.disabled === 'function'
          ? button.disabled()
          : button.disabled;
      buttons.push(
        m(Button, {
          intent: button.primary ? Intent.Primary : Intent.None,
          variant: ButtonVariant.Filled,
          id: button.id,
          onclick: () => {
            closeModal(attrs.key);
            if (button.action !== undefined) button.action();
          },
          label: button.text,
          disabled: isDisabled,
        }),
      );
    }

    const aria = '[aria-labelledby=mm-title][aria-model][role=dialog]';
    const align = attrs.vAlign === 'TOP' ? '.pf-modal-dialog-valign-top' : '';
    const customClass = attrs.className ? '.' + attrs.className : '';
    return m(
      '.pf-modal-backdrop',
      {
        onclick: this.onBackdropClick.bind(this, attrs),
        onkeydown: this.onBackdropKeydown.bind(this, attrs),
        tabIndex: 0,
      },
      m(
        `.pf-modal-dialog${align}${customClass}${aria}`,
        m(
          'header',
          m(
            '.modal-title',
            attrs.icon && m(Icon, {icon: attrs.icon}),
            m('h1', {id: 'mm-title'}, attrs.title),
          ),
          m(
            'button[aria-label=Close Modal]',
            {onclick: () => closeModal(attrs.key)},
            m(Icon, {icon: 'close'}),
          ),
        ),
        m('main', vnode.children),
        buttons.length > 0 ? m('footer', buttons) : null,
      ),
    );
  }

  onBackdropClick(attrs: ModalAttrs, e: MouseEvent) {
    e.stopPropagation();
    // Only react when clicking on the backdrop. Don't close if the user clicks
    // on the dialog itself.
    const t = e.target;
    if (t instanceof Element && t.classList.contains('modal-backdrop')) {
      closeModal(attrs.key);
    }
  }

  onBackdropKeydown(attrs: ModalAttrs, e: KeyboardEvent) {
    e.stopPropagation();
    if (e.key === 'Escape') {
      closeModal(attrs.key);
    }
  }
}

// Set by showModal().
let currentModal: ModalAttrs | undefined = undefined;
let generationCounter = 0;

// This should be called only by app.ts and nothing else.
// This generates the modal dialog at the root of the DOM, so it can overlay
// on top of everything else.
export function maybeRenderFullscreenModalDialog() {
  // We use the generation counter as key to distinguish between: (1) two render
  // passes for the same dialog vs (2) rendering a new dialog that has been
  // created invoking showModal() while another modal dialog was already being
  // shown.
  if (currentModal === undefined) return [];
  let children: m.Children;
  if (currentModal.content === undefined) {
    children = null;
  } else if (typeof currentModal.content === 'function') {
    children = currentModal.content();
  } else {
    children = currentModal.content;
  }
  return [m(Modal, currentModal, children)];
}

// Shows a full-screen modal dialog.
export async function showModal(userAttrs: ModalAttrs): Promise<void> {
  const returnedClosePromise = defer<void>();
  const userOnClose = userAttrs.onClose ?? (() => {});

  // If the user doesn't specify a key (to match the closeModal), generate a
  // random key to distinguish two showModal({key:undefined}) calls.
  const key = userAttrs.key ?? `${++generationCounter}`;
  const attrs: ModalAttrs = {
    ...userAttrs,
    key,
    onClose: () => {
      userOnClose();
      returnedClosePromise.resolve();
    },
  };
  currentModal = attrs;
  redrawModal();
  return returnedClosePromise;
}

// Technically we don't need to redraw the whole app, but it's the more
// pragmatic option. This is exposed to keep the plugin code more clear, so it's
// evident why a redraw is requested.
export function redrawModal() {
  if (currentModal !== undefined) {
    m.redraw();
  }
}

// Closes the full-screen modal dialog (if any).
// `key` is optional: if provided it will close the modal dialog only if the key
// matches. This is to avoid accidentally closing another dialog that popped
// in the meanwhile. If undefined, it closes whatever modal dialog is currently
// open (if any).
export function closeModal(key?: string) {
  if (
    currentModal === undefined ||
    (key !== undefined && currentModal.key !== key)
  ) {
    // Somebody else closed the modal dialog already, or opened a new one with
    // a different key.
    return;
  }
  currentModal = undefined;
  m.redraw();
}

export function getCurrentModalKey(): string | undefined {
  return currentModal?.key;
}
