// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import { UUID } from '@lumino/coreutils';
import { Signal } from '@lumino/signaling';
import { ElementAttrs, VirtualElement, VirtualNode } from '@lumino/virtualdom';
import React from 'react';
import ReactDOM from 'react-dom';

import { LabIconStyle } from '../style';
import { getReactAttrs, classes } from '../utils';

import badSvgstr from '../../style/debug/bad.svg';
import blankSvgstr from '../../style/debug/blank.svg';
import refreshSvgstr from '../../style/icons/toolbar/refresh.svg';

export class LabIcon implements LabIcon.ILabIcon, VirtualElement.IRenderer {
  /** *********
   * statics *
   ***********/

  /**
   * Remove any rendered icon from the element that contains it
   *
   * @param container - a DOM node into which an icon was
   * previously rendered
   *
   * @returns the cleaned container
   */
  static remove(container: HTMLElement) {
    // clean up all children
    while (container.firstChild) {
      container.firstChild.remove();
    }

    // remove all classes
    container.className = '';

    return container;
  }

  /**
   * Resolve an icon name or a {name, svgstr} pair into an
   * actual LabIcon.
   *
   * @param icon - either a string with the name of an existing icon
   * or an object with {name: string, svgstr: string} fields.
   *
   * @returns a LabIcon instance
   */
  static resolve({ icon }: { icon: LabIcon.IResolvable }): LabIcon {
    if (icon instanceof LabIcon) {
      // icon already is a LabIcon; nothing to do here
      return icon;
    }

    if (typeof icon === 'string') {
      // do a dynamic lookup of existing icon by name
      const resolved = LabIcon._instances.get(icon);
      if (resolved) {
        return resolved;
      }

      // lookup failed
      if (LabIcon._debug) {
        // fail noisily
        console.warn(
          `Lookup failed for icon, creating loading icon. icon: ${icon}`
        );
      }

      // no matching icon currently registered, create a new loading icon
      // TODO: find better icon (maybe animate?) for loading icon
      return new LabIcon({ name: icon, svgstr: refreshSvgstr, _loading: true });
    }

    // icon was provided as a non-LabIcon {name, svgstr} pair, communicating
    // an intention to create a new icon
    return new LabIcon(icon);
  }

  /**
   * Resolve an icon name or a {name, svgstr} pair into a DOM element.
   * If icon arg is undefined, the function will fall back to trying to render
   * the icon as a CSS background image, via the iconClass arg.
   * If both icon and iconClass are undefined, this function will return
   * an empty div.
   *
   * @param icon - optional, either a string with the name of an existing icon
   * or an object with {name: string, svgstr: string} fields
   *
   * @param iconClass - optional, if the icon arg is not set, the iconClass arg
   * should be a CSS class associated with an existing CSS background-image
   *
   * @deprecated fallback - don't use, optional, a LabIcon instance that will
   * be used if neither icon nor iconClass are defined
   *
   * @param props - any additional args are passed though to the element method
   * of the resolved icon on render
   *
   * @returns a DOM node with the resolved icon rendered into it
   */
  static resolveElement({
    icon,
    iconClass,
    fallback,
    ...props
  }: Partial<LabIcon.IResolverProps> & LabIcon.IProps) {
    if (!Private.isResolvable(icon)) {
      if (!iconClass && fallback) {
        // if neither icon nor iconClass are defined/resolvable, use fallback
        return fallback.element(props);
      }

      // set the icon's class to iconClass plus props.className
      props.className = classes(iconClass, props.className);
      // render icon as css background image, assuming one is set on iconClass
      return Private.blankElement(props);
    }

    return LabIcon.resolve({ icon }).element(props);
  }

  /**
   * Resolve an icon name or a {name, svgstr} pair into a React component.
   * If icon arg is undefined, the function will fall back to trying to render
   * the icon as a CSS background image, via the iconClass arg.
   * If both icon and iconClass are undefined, the returned component
   * will simply render an empty div.
   *
   * @param icon - optional, either a string with the name of an existing icon
   * or an object with {name: string, svgstr: string} fields
   *
   * @param iconClass - optional, if the icon arg is not set, the iconClass arg
   * should be a CSS class associated with an existing CSS background-image
   *
   * @deprecated fallback - don't use, optional, a LabIcon instance that will
   * be used if neither icon nor iconClass are defined
   *
   * @param props - any additional args are passed though to the React component
   * of the resolved icon on render
   *
   * @returns a React component that will render the resolved icon
   */
  static resolveReact({
    icon,
    iconClass,
    fallback,
    ...props
  }: Partial<LabIcon.IResolverProps> & LabIcon.IReactProps) {
    if (!Private.isResolvable(icon)) {
      if (!iconClass && fallback) {
        // if neither icon nor iconClass are defined/resolvable, use fallback
        return <fallback.react {...props} />;
      }

      // set the icon's class to iconClass plus props.className
      props.className = classes(iconClass, props.className);
      // render icon as css background image, assuming one is set on iconClass
      return <Private.blankReact {...props} />;
    }

    const resolved = LabIcon.resolve({ icon });
    return <resolved.react {...props} />;
  }

  /**
   * Resolve a {name, svgstr} pair into an actual svg node.
   */
  static resolveSvg({ name, svgstr }: LabIcon.IIcon): HTMLElement | null {
    const svgDoc = new DOMParser().parseFromString(
      Private.svgstrShim(svgstr),
      'image/svg+xml'
    );

    const svgError = svgDoc.querySelector('parsererror');

    // structure of error element varies by browser, search at top level
    if (svgError) {
      // parse failed, svgElement will be an error box
      const errmsg = `SVG HTML was malformed for LabIcon instance.\nname: ${name}, svgstr: ${svgstr}`;
      if (LabIcon._debug) {
        // fail noisily, render the error box
        console.error(errmsg);
        return svgError as HTMLElement;
      } else {
        // bad svg is always a real error, fail silently but warn
        console.warn(errmsg);
        return null;
      }
    } else {
      // parse succeeded
      return svgDoc.documentElement;
    }
  }

  /**
   * Toggle icon debug from off-to-on, or vice-versa.
   *
   * @param debug - optional boolean to force debug on or off
   */
  static toggleDebug(debug?: boolean) {
    LabIcon._debug = debug ?? !LabIcon._debug;
  }

  private static _debug: boolean = false;
  private static _instances = new Map<string, LabIcon>();

  /** *********
   * members *
   ***********/

  constructor({
    name,
    svgstr,
    render,
    unrender,
    _loading = false
  }: LabIcon.IOptions & { _loading?: boolean }) {
    if (!(name && svgstr)) {
      // sanity check failed
      console.error(
        `When defining a new LabIcon, name and svgstr must both be non-empty strings. name: ${name}, svgstr: ${svgstr}`
      );
      return badIcon;
    }

    // currently this needs to be set early, before checks for existing icons
    this._loading = _loading;

    // check to see if this is a redefinition of an existing icon
    if (LabIcon._instances.has(name)) {
      // fetch the existing icon, replace its svg, then return it
      const icon = LabIcon._instances.get(name)!;
      if (this._loading) {
        // replace the placeholder svg in icon
        icon.svgstr = svgstr;
        this._loading = false;
        return icon;
      } else {
        // already loaded icon svg exists; replace it and warn
        // TODO: need to see if this warning is useful or just noisy
        console.warn(
          `Redefining previously loaded icon svgstr. name: ${name}, svgstrOld: ${icon.svgstr}, svgstr: ${svgstr}`
        );
        icon.svgstr = svgstr;
        return icon;
      }
    }

    this.name = name;
    this.react = this._initReact(name);
    this.svgstr = svgstr;

    // setup custom render/unrender methods, if passed in
    this._initRender({ render, unrender });

    LabIcon._instances.set(this.name, this);
  }

  /**
   * Get a view of this icon that is bound to the specified icon/style props
   *
   * @param optional icon/style props (same as args for .element
   * and .react methods). These will be bound to the resulting view
   *
   * @returns a view of this LabIcon instance
   */
  bindprops(props?: LabIcon.IProps) {
    const view = Object.create(this);
    view._props = props;
    view.react = view._initReact(view.name + '_bind');
    return view;
  }

  /**
   * Create an icon as a DOM element
   *
   * @param className - a string that will be used as the class
   * of the container element. Overrides any existing class
   *
   * @param container - a preexisting DOM element that
   * will be used as the container for the svg element
   *
   * @param label - text that will be displayed adjacent
   * to the icon
   *
   * @param title - a tooltip for the icon
   *
   * @param tag - if container is not explicitly
   * provided, this tag will be used when creating the container
   *
   * @param stylesheet - optional string naming a builtin icon
   * stylesheet, for example 'menuItem' or `statusBar`. Can also be an
   * object defining a custom icon stylesheet, or a list of builtin
   * stylesheet names and/or custom stylesheet objects. If array,
   * the given stylesheets will be merged.
   *
   *   See @jupyterlab/ui-components/src/style/icon.ts for details
   *
   * @param elementPosition - optional position for the inner svg element
   *
   * @param elementSize - optional size for the inner svg element.
   * Set to 'normal' to get a standard 16px x 16px icon
   *
   * @param ...elementCSS - all additional args are treated as
   * overrides for the CSS props applied to the inner svg element
   *
   * @returns A DOM element that contains an (inline) svg element
   * that displays an icon
   */
  element(props: LabIcon.IProps = {}): HTMLElement {
    let {
      className,
      container,
      label,
      title,
      tag = 'div',
      ...styleProps
    }: LabIcon.IProps = { ...this._props, ...props };

    // check if icon element is already set
    const maybeSvgElement = container?.firstChild as HTMLElement;
    if (maybeSvgElement?.dataset?.iconId === this._uuid) {
      // return the existing icon element
      return maybeSvgElement;
    }

    // ensure that svg html is valid
    if (!this.svgElement) {
      // bail if failing silently, return blank element
      return document.createElement('div');
    }

    let returnSvgElement = true;
    if (container) {
      // take ownership by removing any existing children
      while (container.firstChild) {
        container.firstChild.remove();
      }
    } else {
      // create a container if needed
      container = document.createElement(tag);

      returnSvgElement = false;
    }
    if (label != null) {
      container.textContent = label;
    }
    Private.initContainer({ container, className, styleProps, title });

    // add the svg node to the container
    const svgElement = this.svgElement.cloneNode(true) as HTMLElement;
    container.appendChild(svgElement);

    return returnSvgElement ? svgElement : container;
  }

  render(container: HTMLElement, options?: LabIcon.IRendererOptions): void {
    let label = options?.children?.[0];
    // narrow type of label
    if (typeof label !== 'string') {
      label = undefined;
    }

    this.element({
      container,
      label,
      ...options?.props
    });
  }

  protected get svgElement(): HTMLElement | null {
    if (this._svgElement === undefined) {
      this._svgElement = this._initSvg({ uuid: this._uuid });
    }

    return this._svgElement;
  }

  protected get svgInnerHTML(): string | null {
    if (this._svgInnerHTML === undefined) {
      if (this.svgElement === null) {
        // the svg element resolved to null, mark this null too
        this._svgInnerHTML = null;
      } else {
        this._svgInnerHTML = this.svgElement.innerHTML;
      }
    }

    return this._svgInnerHTML;
  }

  protected get svgReactAttrs(): any | null {
    if (this._svgReactAttrs === undefined) {
      if (this.svgElement === null) {
        // the svg element resolved to null, mark this null too
        this._svgReactAttrs = null;
      } else {
        this._svgReactAttrs = getReactAttrs(this.svgElement, {
          ignore: ['data-icon-id']
        });
      }
    }

    return this._svgReactAttrs;
  }

  get svgstr() {
    return this._svgstr;
  }

  set svgstr(svgstr: string) {
    this._svgstr = svgstr;

    // associate a new unique id with this particular svgstr
    const uuid = UUID.uuid4();
    const uuidOld = this._uuid;
    this._uuid = uuid;

    // empty the svg parsing intermediates cache
    this._svgElement = undefined;
    this._svgInnerHTML = undefined;
    this._svgReactAttrs = undefined;

    // update icon elements created using .element method
    document
      .querySelectorAll(`[data-icon-id="${uuidOld}"]`)
      .forEach(oldSvgElement => {
        if (this.svgElement) {
          oldSvgElement.replaceWith(this.svgElement.cloneNode(true));
        }
      });

    // trigger update of icon elements created using other methods
    this._svgReplaced.emit();
  }

  unrender?(container: HTMLElement, options?: LabIcon.IRendererOptions): void;

  protected _initReact(displayName: string) {
    const component = React.forwardRef(
      (props: LabIcon.IProps = {}, ref: LabIcon.IReactRef) => {
        const {
          className,
          container,
          label,
          title,
          tag = 'div',
          ...styleProps
        }: LabIcon.IProps = { ...this._props, ...props };

        // set up component state via useState hook
        const [, setId] = React.useState(this._uuid);

        // subscribe to svg replacement via useEffect hook
        React.useEffect(() => {
          const onSvgReplaced = () => {
            setId(this._uuid);
          };

          this._svgReplaced.connect(onSvgReplaced);

          // specify cleanup callback as hook return
          return () => {
            this._svgReplaced.disconnect(onSvgReplaced);
          };
        });

        // make it so that tag can be used as a jsx component
        const Tag = tag;

        // ensure that svg html is valid
        if (!(this.svgInnerHTML && this.svgReactAttrs)) {
          // bail if failing silently
          return <></>;
        }

        const svgComponent = (
          <svg
            {...this.svgReactAttrs}
            dangerouslySetInnerHTML={{ __html: this.svgInnerHTML }}
            ref={ref}
          />
        );

        if (container) {
          Private.initContainer({ container, className, styleProps, title });

          return (
            <React.Fragment>
              {svgComponent}
              {label}
            </React.Fragment>
          );
        } else {
          return (
            <Tag
              className={classes(
                className,
                LabIconStyle.styleClass(styleProps)
              )}
            >
              {svgComponent}
              {label}
            </Tag>
          );
        }
      }
    );

    component.displayName = `LabIcon_${displayName}`;
    return component;
  }

  protected _initRender({
    render,
    unrender
  }: Partial<VirtualElement.IRenderer>) {
    if (render) {
      this.render = render;
      if (unrender) {
        this.unrender = unrender;
      }
    } else if (unrender) {
      console.warn(
        'In _initRender, ignoring unrender arg since render is undefined'
      );
    }
  }

  protected _initSvg({
    title,
    uuid
  }: { title?: string; uuid?: string } = {}): HTMLElement | null {
    const svgElement = LabIcon.resolveSvg(this);

    if (!svgElement) {
      // bail on null svg element
      return svgElement;
    }

    if (svgElement.tagName !== 'parsererror') {
      // svgElement is an actual svg node, augment it
      svgElement.dataset.icon = this.name;

      if (uuid) {
        svgElement.dataset.iconId = uuid;
      }

      if (title) {
        Private.setTitleSvg(svgElement, title);
      }
    }

    return svgElement;
  }

  readonly name: string;

  /**
   * A React component that will create the icon.
   *
   * @param className - a string that will be used as the class
   * of the container element. Overrides any existing class
   *
   * @param container - a preexisting DOM element that
   * will be used as the container for the svg element
   *
   * @param label - text that will be displayed adjacent
   * to the icon
   *
   * @param title - a tooltip for the icon
   *
   * @param tag - if container is not explicitly
   * provided, this tag will be used when creating the container
   *
   * @param stylesheet - optional string naming a builtin icon
   * stylesheet, for example 'menuItem' or `statusBar`. Can also be an
   * object defining a custom icon stylesheet, or a list of builtin
   * stylesheet names and/or custom stylesheet objects. If array,
   * the given stylesheets will be merged.
   *
   *   See @jupyterlab/ui-components/src/style/icon.ts for details
   *
   * @param elementPosition - optional position for the inner svg element
   *
   * @param elementSize - optional size for the inner svg element.
   * Set to 'normal' to get a standard 16px x 16px icon
   *
   * @param ...elementCSS - all additional args are treated as
   * overrides for the CSS props applied to the inner svg element
   *
   * @param ref - forwarded to the ref prop of the icon's svg element
   */
  readonly react: LabIcon.IReact;

  protected _className: string;
  protected _loading: boolean;
  protected _props: LabIcon.IProps = {};
  protected _svgReplaced = new Signal<this, void>(this);
  protected _svgstr: string;
  protected _uuid: string;

  /**
   * Cache for svg parsing intermediates
   *   - undefined: the cache has not yet been populated
   *   - null: a valid, but empty, value
   */
  protected _svgElement: HTMLElement | null | undefined = undefined;
  protected _svgInnerHTML: string | null | undefined = undefined;
  protected _svgReactAttrs: any | null | undefined = undefined;
}

/**
 * A namespace for LabIcon statics.
 */
export namespace LabIcon {
  /** ***********
   * interfaces *
   *************/

  /**
   * The simplest possible interface for defining a generic icon.
   */
  export interface IIcon {
    /**
     * The name of the icon. By convention, the icon name will be namespaced
     * as so:
     *
     *     "pkg-name:icon-name"
     */
    readonly name: string;

    /**
     * A string containing the raw contents of an svg file.
     */
    svgstr: string;
  }

  export interface IRendererOptions {
    attrs?: ElementAttrs;
    children?: ReadonlyArray<VirtualNode>;
    props?: IProps;
  }

  /**
   * The ILabIcon interface. Outside of this interface the actual
   * implementation of LabIcon may vary
   */
  export interface ILabIcon extends IIcon, VirtualElement.IRenderer {}

  /**
   * Interface defining the parameters to be passed to the LabIcon
   * constructor
   */
  export interface IOptions extends IIcon, Partial<VirtualElement.IRenderer> {}

  /**
   * The input props for creating a new LabIcon
   */
  export interface IProps extends LabIconStyle.IProps {
    /**
     * Extra classNames. Used in addition to the typestyle className to
     * set the className of the icon's outermost container node
     */
    className?: string;

    /**
     * The icon's outermost node, which acts as a container for the actual
     * svg node. If container is not supplied, it will be created
     */
    container?: HTMLElement;

    /**
     * Optional text label that will be added as a sibling to the icon's
     * svg node
     */
    label?: string;

    /**
     * HTML element tag used to create the icon's outermost container node,
     * if no container is passed in
     */
    tag?: 'div' | 'span';

    /**
     * Optional title that will be set on the icon's outermost container node
     */
    title?: string;
  }

  export interface IResolverProps {
    icon?: IMaybeResolvable;
    iconClass?: string;
    fallback?: LabIcon;
  }

  /** ******
   * types *
   *********/

  /**
   * A type that can be resolved to a LabIcon instance.
   */
  export type IResolvable =
    | string
    | (IIcon & Partial<VirtualElement.IRenderer>);

  /**
   * A type that maybe can be resolved to a LabIcon instance.
   */
  export type IMaybeResolvable =
    | IResolvable
    | VirtualElement.IRenderer
    | undefined;

  /**
   * The type of the svg node ref that can be passed into icon React components
   */
  export type IReactRef = React.RefObject<SVGElement>;

  /**
   * The properties that can be passed into the React component stored in
   * the .react field of a LabIcon.
   */
  export type IReactProps = IProps & React.RefAttributes<SVGElement>;

  /**
   * The complete type of the React component stored in the .react
   * field of a LabIcon.
   */
  export type IReact = React.ForwardRefExoticComponent<IReactProps>;
}

namespace Private {
  export function blankElement({
    className = '',
    container,
    label,
    title,
    tag = 'div',
    ...styleProps
  }: LabIcon.IProps): HTMLElement {
    if (container?.className === className) {
      // nothing needs doing, return the icon node
      return container;
    }

    if (container) {
      // take ownership by removing any existing children
      while (container.firstChild) {
        container.firstChild.remove();
      }
    } else {
      // create a container if needed
      container = document.createElement(tag);
    }
    if (label != null) {
      container.textContent = label;
    }
    Private.initContainer({ container, className, styleProps, title });

    return container;
  }

  export const blankReact = React.forwardRef(
    (
      {
        className = '',
        container,
        label,
        title,
        tag = 'div',
        ...styleProps
      }: LabIcon.IProps,
      ref: LabIcon.IReactRef
    ) => {
      // make it so that tag can be used as a jsx component
      const Tag = tag;

      if (container) {
        initContainer({ container, className, styleProps, title });

        return <></>;
      } else {
        // if ref is defined, we create a blank svg node and point ref to it
        return (
          <Tag
            className={classes(className, LabIconStyle.styleClass(styleProps))}
          >
            {ref && blankIcon.react({ ref })}
            {label}
          </Tag>
        );
      }
    }
  );
  blankReact.displayName = 'BlankReact';

  export function initContainer({
    container,

    className,
    styleProps,
    title
  }: {
    container: HTMLElement;
    className?: string;
    styleProps?: LabIconStyle.IProps;
    title?: string;
  }): string {
    if (title != null) {
      container.title = title;
    }
    const styleClass = LabIconStyle.styleClass(styleProps);

    if (className != null) {
      // override the container class with explicitly passed-in class + style class
      const classResolved = classes(className, styleClass);
      container.className = classResolved;
      return classResolved;
    } else if (styleClass) {
      // add the style class to the container class
      container.classList.add(styleClass);
      return styleClass;
    } else {
      return '';
    }
  }

  export function isResolvable(
    icon: LabIcon.IMaybeResolvable
  ): icon is LabIcon.IResolvable {
    return !!(
      icon &&
      (typeof icon === 'string' ||
        ((icon as LabIcon.IIcon).name && (icon as LabIcon.IIcon).svgstr))
    );
  }

  export function setTitleSvg(svgNode: HTMLElement, title: string): void {
    // add a title node to the top level svg node
    const titleNodes = svgNode.getElementsByTagName('title');
    if (titleNodes.length) {
      titleNodes[0].textContent = title;
    } else {
      const titleNode = document.createElement('title');
      titleNode.textContent = title;
      svgNode.appendChild(titleNode);
    }
  }

  /**
   * A shim for svgstrs loaded using any loader other than raw-loader.
   * This function assumes that svgstr will look like one of:
   *
   * - the raw contents of an .svg file:
   *   <svg...</svg>
   *
   * - a data URL:
   *   data:[<mediatype>][;base64],<svg...</svg>
   *
   * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
   */
  export function svgstrShim(svgstr: string, strict: boolean = true): string {
    // decode any uri escaping, condense leading/lagging whitespace,
    // then match to raw svg string
    const [, base64, raw] = decodeURIComponent(svgstr)
      .replace(/>\s*\n\s*</g, '><')
      .replace(/\s*\n\s*/g, ' ')
      .match(
        strict
          ? // match based on data url schema
            /^(?:data:.*?(;base64)?,)?(.*)/
          : // match based on open of svg tag
            /(?:(base64).*)?(<svg.*)/
      )!;

    // decode from base64, if needed
    return base64 ? atob(raw) : raw;
  }

  /**
   * TODO: figure out story for independent Renderers.
   * Base implementation of IRenderer.
   */
  export class Renderer implements VirtualElement.IRenderer {
    constructor(
      protected _icon: LabIcon,
      protected _rendererOptions?: LabIcon.IRendererOptions
    ) {}

    // eslint-disable-next-line
    render(container: HTMLElement, options?: LabIcon.IRendererOptions): void {}
    unrender?(container: HTMLElement, options?: LabIcon.IRendererOptions): void;
  }

  /**
   * TODO: figure out story for independent Renderers.
   * Implementation of IRenderer that creates the icon svg node
   * as a DOM element.
   */
  export class ElementRenderer extends Renderer {
    render(container: HTMLElement, options?: LabIcon.IRendererOptions): void {
      let label = options?.children?.[0];
      // narrow type of label
      if (typeof label !== 'string') {
        label = undefined;
      }

      this._icon.element({
        container,
        label,
        ...this._rendererOptions?.props,
        ...options?.props
      });
    }
  }

  /**
   * TODO: figure out story for independent Renderers.
   * Implementation of IRenderer that creates the icon svg node
   * as a React component.
   */
  export class ReactRenderer extends Renderer {
    render(container: HTMLElement, options?: LabIcon.IRendererOptions): void {
      let label = options?.children?.[0];
      // narrow type of label
      if (typeof label !== 'string') {
        label = undefined;
      }

      ReactDOM.render(
        <this._icon.react
          container={container}
          label={label}
          {...{ ...this._rendererOptions?.props, ...options?.props }}
        />,
        container
      );
    }

    unrender(container: HTMLElement): void {
      ReactDOM.unmountComponentAtNode(container);
    }
  }
}

// need to be at the bottom since constructor depends on Private
export const badIcon = new LabIcon({
  name: 'ui-components:bad',
  svgstr: badSvgstr
});
export const blankIcon = new LabIcon({
  name: 'ui-components:blank',
  svgstr: blankSvgstr
});
