/**
 * @license
 * Copyright 2020 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
interface ElementWithShadowRoot extends Element {
  shadowRoot: ShadowRoot;
}

/**
 * Type guard for element with a shadowRoot.
 */
function isElementWithShadowRoot(
  el: Element | ShadowRoot
): el is ElementWithShadowRoot {
  return 'shadowRoot' in el;
}

export function isElement(node: Node): node is Element {
  return node.nodeType === 1;
}

export function isElementTarget(
  target: EventTarget | null | undefined
): target is Element {
  if (!target) return false;
  return 'nodeType' in target && isElement(target as Node);
}

// TODO: maybe should have a better name for this
function getPathFromNode(el: EventTarget) {
  let tagName = '';
  let id = '';
  let className = '';
  if (el instanceof Element) {
    tagName = el.tagName;
    id = el.id;
    className = el.className;
  }
  if (
    !tagName ||
    'GR-APP' === tagName ||
    el instanceof DocumentFragment ||
    el instanceof HTMLSlotElement
  ) {
    return '';
  }
  let path = '';
  if (tagName) {
    path += tagName.toLowerCase();
  }
  if (id) {
    path += `#${id}`;
  }
  if (className) {
    path += `.${className.replace(/ /g, '.')}`;
  }
  return path;
}

/**
 * Get computed style value.
 */
export function getComputedStyleValue(name: string, el: Element) {
  return getComputedStyle(el).getPropertyValue(name).trim();
}

/**
 * Query selector on a dom element.
 *
 * This is shadow DOM compatible, but only works when selector is within
 * one shadow host, won't work if your selector is crossing
 * multiple shadow hosts.
 *
 */
export function querySelector(
  el: Element | ShadowRoot,
  selector: string
): Element | null {
  let nodes = [el];
  let result = null;
  while (nodes.length) {
    const node = nodes.pop();

    // Skip if it's an invalid node.
    if (!node || !node.querySelector) continue;

    // Try find it with native querySelector directly
    result = node.querySelector(selector);

    if (result) {
      break;
    }

    // Add all nodes with shadowRoot and loop through
    const allShadowNodes = [...node.querySelectorAll('*')]
      .filter(isElementWithShadowRoot)
      .map(child => child.shadowRoot);
    nodes = nodes.concat(allShadowNodes);

    // Add shadowRoot of current node if has one
    // as its not included in node.querySelectorAll('*')
    if (isElementWithShadowRoot(node)) {
      nodes.push(node.shadowRoot);
    }
  }
  return result;
}

/**
 * Query selector all dom elements matching with certain selector.
 *
 * This is shadow DOM compatible, but only works when selector is within
 * one shadow host, won't work if your selector is crossing
 * multiple shadow hosts.
 *
 * Note: this can be very expensive, only use when have to.
 */
export function querySelectorAll(
  el: Element | ShadowRoot,
  selector: string
): Element[] {
  let nodes = [el];
  const results = new Set<Element>();
  while (nodes.length) {
    const node = nodes.pop();

    if (!node || !node.querySelectorAll) continue;

    // Try find all from regular children
    [...node.querySelectorAll(selector)].forEach(el => results.add(el));

    // Add all nodes with shadowRoot and loop through
    const allShadowNodes = [...node.querySelectorAll('*')]
      .filter(isElementWithShadowRoot)
      .map(child => child.shadowRoot);
    nodes = nodes.concat(allShadowNodes);

    // Add shadowRoot of current node if has one
    // as its not included in node.querySelectorAll('*')
    if (isElementWithShadowRoot(node)) {
      nodes.push(node.shadowRoot);
    }
  }
  return [...results];
}

export function windowLocationReload() {
  const e = new Error();
  console.info(`Calling window.location.reload(): ${e.stack}`);
  window.location.reload();
}

/**
 * Retrieves the dom path of the current event.
 *
 * If the event object contains a `path` property, then use it,
 * otherwise, construct the dom path based on the event target.
 *
 * domNode.onclick = e => {
 *  getEventPath(e); // eg: div.class1>p#pid.class2
 * }
 */
export function getEventPath<T extends MouseEvent>(e?: T) {
  if (!e) return '';

  let path = e.composedPath();
  if (!path || !path.length) {
    path = [];
    let el = e.target;
    while (el) {
      path.push(el);
      el = (el as Node).parentNode || (el as ShadowRoot).host;
    }
  }

  return path.reduce<string>((domPath: string, curEl: EventTarget) => {
    const pathForEl = getPathFromNode(curEl);
    if (!pathForEl) return domPath;
    return domPath ? `${pathForEl}>${domPath}` : pathForEl;
  }, '');
}

/**
 * Are any ancestors of the element (or the element itself) members of the
 * given class.
 *
 */
export function descendedFromClass(
  element: Element,
  className: string,
  stopElement?: Element
) {
  let isDescendant = element.classList.contains(className);
  while (
    !isDescendant &&
    element.parentElement &&
    (!stopElement || element.parentElement !== stopElement)
  ) {
    isDescendant = element.classList.contains(className);
    element = element.parentElement;
  }
  return isDescendant;
}

/**
 * Convert any string into a valid class name.
 *
 * For class names, naming rules:
 * Must begin with a letter A-Z or a-z
 * Can be followed by: letters (A-Za-z), digits (0-9), hyphens ("-"), and underscores ("_")
 */
export function strToClassName(str = '', prefix = 'generated_') {
  return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
}

// document.activeElement is not enough, because it's not getting activeElement
// without looking inside of shadow roots. This will find best activeElement.
export function findActiveElement(
  root: Document | ShadowRoot | null,
  ignoreDialogs?: boolean
): HTMLElement | null {
  if (root === null) {
    return null;
  }
  if (
    ignoreDialogs &&
    root.activeElement &&
    root.activeElement.nodeName.toUpperCase().includes('DIALOG')
  ) {
    return null;
  }
  if (root.activeElement?.shadowRoot?.activeElement) {
    return findActiveElement(root.activeElement.shadowRoot);
  }
  if (!root.activeElement) {
    return null;
  }
  // We block some elements
  if ('BODY' === root.activeElement.nodeName.toUpperCase()) {
    return null;
  }
  return root.activeElement as HTMLElement;
}

// Whether the browser is Safari. Used for polyfilling unique browser behavior.
export function isSafari() {
  return (
    /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
    /iPad|iPhone|iPod/.test(navigator.userAgent)
  );
}

export function whenVisible(
  element: Element,
  callback: () => void,
  marginPx = 0
) {
  const observer = new IntersectionObserver(
    (entries: IntersectionObserverEntry[]) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          observer.unobserve(entry.target);
          callback();
          return;
        }
      }
    },
    {rootMargin: `${marginPx}px`}
  );
  observer.observe(element);
}

/**
 * Toggles a CSS class on or off for an element.
 */
export function toggleClass(el: Element, className: string, bool?: boolean) {
  if (bool === undefined) {
    bool = !el.classList.contains(className);
  }
  if (bool) {
    el.classList.add(className);
  } else {
    el.classList.remove(className);
  }
}

/**
 * For matching the `key` property of KeyboardEvents. These are known to work
 * with Firefox, Safari and Chrome.
 */
export enum Key {
  ENTER = 'Enter',
  ESC = 'Escape',
  TAB = 'Tab',
  SPACE = ' ',
  LEFT = 'ArrowLeft',
  RIGHT = 'ArrowRight',
  UP = 'ArrowUp',
  DOWN = 'ArrowDown',
}

export enum Modifier {
  ALT_KEY,
  CTRL_KEY,
  META_KEY,
  SHIFT_KEY,
}

export enum ComboKey {
  G = 'g',
  V = 'v',
}

export interface Binding {
  key: string | Key;
  /** Defaults to false. */
  docOnly?: boolean;
  /** Defaults to not being a combo shortcut. */
  combo?: ComboKey;
  /** Defaults to no modifiers. */
  modifiers?: Modifier[];
  /** Defaults to false. If true, then `event.repeat === true` is allowed. */
  allowRepeat?: boolean;
}

const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/);

/**
 * For "normal" keys we do not check that the SHIFT modifier is pressed or not,
 * because that depends on the keyboard layout. Just checking the key string is
 * sufficient.
 *
 * But for some special keys it is important whether SHIFT is pressed at the
 * same time, for example we want to distinguish Enter from Shift+Enter.
 */
function shiftMustMatch(key: string | Key) {
  return Object.values(Key).includes(key as Key);
}

/**
 * For a-zA-Z0-9 and for Enter, Tab, etc. we want to check the ALT modifier.
 *
 * But for special chars like []/? we don't care whether the user is pressing
 * the ALT modifier to produce the special char. For example on a German
 * keyboard layout you have to press ALT to produce a [.
 */
function altMustMatch(key: string | Key) {
  return ALPHA_NUM.test(key) || Object.values(Key).includes(key as Key);
}

export function eventMatchesShortcut(
  e: KeyboardEvent,
  shortcut: Binding
): boolean {
  if (e.key !== shortcut.key) return false;
  const modifiers = shortcut.modifiers ?? [];
  if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return false;
  if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return false;
  if (
    altMustMatch(e.key) &&
    e.altKey !== modifiers.includes(Modifier.ALT_KEY)
  ) {
    return false;
  }
  if (
    shiftMustMatch(e.key) &&
    e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY)
  ) {
    return false;
  }
  return true;
}

export interface ShortcutOptions {
  /**
   * Do you want to suppress events from <input> elements and such?
   */
  shouldSuppress?: boolean;
  /**
   * Do you want to take care of calling preventDefault() and
   * stopPropagation() yourself? Then set this option to `false`.
   */
  preventDefault?: boolean;
}

/**
 * @deprecated
 *
 * For LitElement use the shortcut-controller.
 */
export function addShortcut(
  element: HTMLElement,
  shortcut: Binding,
  listener: (e: KeyboardEvent) => void,
  options?: ShortcutOptions
) {
  const optShouldSuppress = options?.shouldSuppress ?? false;
  const optPreventDefault = options?.preventDefault ?? true;
  const wrappedListener = (e: KeyboardEvent) => {
    if (e.repeat && !shortcut.allowRepeat) return;
    if (optShouldSuppress && shouldSuppress(e)) return;
    if (!eventMatchesShortcut(e, shortcut)) return;
    if (optPreventDefault) e.preventDefault();
    if (optPreventDefault) e.stopPropagation();
    listener(e);
  };
  element.addEventListener('keydown', wrappedListener);
  return () => element.removeEventListener('keydown', wrappedListener);
}

export function modifierPressed(e: KeyboardEvent) {
  return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
}

export function shiftPressed(e: KeyboardEvent) {
  return e.shiftKey;
}

/**
 * When you listen on keyboard events, then within Gerrit's web app you may want
 * to avoid firing in certain common scenarios such as key strokes from <input>
 * elements. But this can also be undesirable, for example Ctrl-Enter from
 * <input> should trigger a save event.
 *
 * The shortcuts-service has a stateful method `shouldSuppress()` with
 * reporting functionality, which delegates to here.
 */
export function shouldSuppress(e: KeyboardEvent): boolean {
  // Note that when you listen on document, then `e.currentTarget` will be the
  // document and `e.target` will be `<gr-app>` due to shadow dom, but by
  // using the composedPath() you can actually find the true origin of the
  // event.
  const rootTarget = e.composedPath()[0];
  if (!isElementTarget(rootTarget)) return false;
  const tagName = rootTarget.tagName;
  const type = rootTarget.getAttribute('type');

  if (
    // Suppress shortcuts on <input> and <textarea>, but not on
    // checkboxes, because we want to enable workflows like 'click
    // mark-reviewed and then press ] to go to the next file'.
    (tagName === 'INPUT' && type !== 'checkbox') ||
    tagName === 'TEXTAREA' ||
    (e.key === 'Enter' &&
      (tagName === 'A' ||
        tagName === 'BUTTON' ||
        tagName === 'GR-BUTTON' ||
        tagName === 'PAPER-TAB'))
  ) {
    return true;
  }
  const path: EventTarget[] = e.composedPath() ?? [];
  for (const el of path) {
    if (!isElementTarget(el)) continue;
    if (el.tagName === 'GR-OVERLAY') return true;
  }
  return false;
}

/** Returns a promise that waits for the element's height to become > 0. */
export function untilRendered(el: HTMLElement) {
  return new Promise(resolve => {
    whenRendered(el, resolve);
  });
}

/** Executes the given callback when the element's height is > 0. */
export function whenRendered(
  el: HTMLElement,
  callback: (value?: unknown) => void
) {
  if (el.clientHeight > 0) {
    callback();
    return;
  }
  const obs = new ResizeObserver(() => {
    if (el.clientHeight > 0) {
      callback();
      obs.unobserve(el);
    }
  });
  obs.observe(el);
}
