import { Observer } from "../core/observer";
import { OwlEvent } from "../core/owl_event";
import { CompiledTemplate, QWeb } from "../qweb/index";
import { patch, VNode } from "../vdom/index";
import "./directive";
import { Fiber } from "./fiber";
import "./props_validation";
import { Scheduler, scheduler } from "./scheduler";
import { activateSheet } from "./styles";
import { Browser, browser } from "../browser";

/**
 * Owl Component System
 *
 * This file introduces a declarative and composable component system. It
 * contains:
 *
 * - the Env interface (generic type for the environment)
 * - the Internal interface (the owl specific metadata attached to a component)
 * - the Component class
 */

//------------------------------------------------------------------------------
// Types/helpers
//------------------------------------------------------------------------------

/**
 * An Env (environment) is an object that will be (mostly) shared between all
 * components of an Owl application.  It is the location which should contain
 * the qweb instance necessary to render all components.
 *
 * Note that it is totally fine to extend the environment with application
 * specific keys/objects/whatever.  For example, a key `isMobile` (to declare
 * if we are in "mobile" mode), or a shared bus could be useful.
 */
export interface Env {
  qweb: QWeb;
  browser: Browser;
}

export type MountPosition = "first-child" | "last-child" | "self";

interface MountOptions {
  position?: MountPosition;
}

export const enum STATUS {
  CREATED,
  WILLSTARTED, // willstart has been called
  RENDERED, // first render is completed (so, vnode is now defined)
  MOUNTED, // is ready, and in DOM. It has a valid el
  UNMOUNTED, // has a valid el, but is not in DOM
  DESTROYED,
}

/**
 * This is mostly an internal detail of implementation. The Meta interface is
 * useful to typecheck and describe the internal keys used by Owl to manage the
 * component tree.
 */
interface Internal<T extends Env> {
  // each component has a unique id, useful mostly to handle parent/child
  // relationships
  readonly id: number;
  depth: number;
  vnode: VNode | null;
  pvnode: VNode | null;
  status: STATUS;

  // parent and children keys are obviously useful to setup the parent-children
  // relationship.
  parent: Component<any, T> | null;
  children: { [key: number]: Component<any, T> };
  // children mapping: from templateID to componentID. templateID identifies a
  // place in a template. The t-component directive needs it to be able to get
  // the component instance back whenever the template is rerendered.
  cmap: { [key: number]: number };

  currentFiber: Fiber | null;
  // parentLastFiberId is there to help the parent component to detect, among
  // its children, those that are not used anymore and thus can be destroyed
  parentLastFiberId: number;

  // when a rendering is initiated by a parent, it may set variables in 'scope'
  // (typically when the component is rendered in a slot). We need to
  // store that information in case the component would be re-rendered later on.
  scope: any;

  boundHandlers: { [key: number]: any };
  observer: Observer | null;
  renderFn: CompiledTemplate;
  mountedCB: Function | null;
  willUnmountCB: Function | null;
  willPatchCB: Function | null;
  patchedCB: Function | null;
  willStartCB: Function | null;
  willUpdatePropsCB: Function | null;
  classObj: { [key: string]: boolean } | null;
  refs: { [key: string]: Component<any, T> | HTMLElement | undefined } | null;
}

export const portalSymbol = Symbol("portal"); // FIXME

//------------------------------------------------------------------------------
// Component
//------------------------------------------------------------------------------
let nextId = 1;

export class Component<Props extends {} = any, T extends Env = Env> {
  readonly __owl__: Internal<T>;
  static template?: string | null = null;
  static _template?: string | null = null;
  static current: Component | null = null;
  static components = {};
  static props?: any;
  static defaultProps?: any;
  static env: any = {};
  // expose scheduler s.t. it can be mocked for testing purposes
  static scheduler: Scheduler = scheduler;

  /**
   * The `el` is the root element of the component.  Note that it could be null:
   * this is the case if the component is not mounted yet, or is destroyed.
   */
  get el(): HTMLElement | null {
    return this.__owl__.vnode ? (<any>this).__owl__.vnode.elm : null;
  }

  env: T;
  props: Props;

  //--------------------------------------------------------------------------
  // Lifecycle
  //--------------------------------------------------------------------------

  /**
   * Creates an instance of Component.
   *
   * Note that most of the time, only the root component needs to be created by
   * hand.  Other components should be created automatically by the framework (with
   * the t-component directive in a template)
   */
  constructor(parent?: Component<any, T> | null, props?: Props) {
    Component.current = this;

    let constr = this.constructor as any;
    const defaultProps = constr.defaultProps;
    if (defaultProps) {
      props = props || ({} as Props);
      this.__applyDefaultProps(props, defaultProps);
    }
    this.props = <Props>props;
    if (QWeb.dev) {
      QWeb.utils.validateProps(constr, this.props);
    }

    const id: number = nextId++;
    let depth;
    if (parent) {
      this.env = parent.env;
      const __powl__ = parent.__owl__;
      __powl__.children[id] = this;
      depth = __powl__.depth + 1;
    } else {
      // we are the root component
      this.env = (this.constructor as any).env;
      if (!this.env.qweb) {
        this.env.qweb = new QWeb();
      }
      // TODO: remove this in owl 2.0
      if (!this.env.browser) {
        this.env.browser = browser;
      }
      this.env.qweb.on("update", this, () => {
        switch (this.__owl__.status) {
          case STATUS.MOUNTED:
            this.render(true);
            break;
          case STATUS.DESTROYED:
            // this is unlikely to happen, but if a root widget is destroyed,
            // we want to remove our subscription.  The usual way to do that
            // would be to perform some check in the destroy method, but since
            // it is very performance sensitive, and since this is a rare event,
            // we simply do it lazily
            this.env.qweb.off("update", this);
            break;
        }
      });
      depth = 0;
    }

    const qweb = this.env.qweb;
    const template = constr.template || this.__getTemplate(qweb);
    this.__owl__ = {
      id: id,
      depth: depth,
      vnode: null,
      pvnode: null,
      status: STATUS.CREATED,
      parent: parent || null,
      children: {},
      cmap: {},
      currentFiber: null,
      parentLastFiberId: 0,
      boundHandlers: {},
      mountedCB: null,
      willUnmountCB: null,
      willPatchCB: null,
      patchedCB: null,
      willStartCB: null,
      willUpdatePropsCB: null,
      observer: null,
      renderFn: qweb.render.bind(qweb, template),
      classObj: null,
      refs: null,
      scope: null,
    };
    if (constr.style) {
      this.__applyStyles(constr);
    }
    this.setup();
  }

  /**
   * setup is run just after the component is constructed. This is the standard
   * location where the component can setup its hooks. It has some advantages
   * over the constructor:
   *  - it can be patched (useful in odoo ecosystem)
   *  - it does not need to propagate the arguments to the super call
   *
   * Note: this method should not be called manually.
   */
  setup() {}

  /**
   * willStart is an asynchronous hook that can be implemented to perform some
   * action before the initial rendering of a component.
   *
   * It will be called exactly once before the initial rendering. It is useful
   * in some cases, for example, to load external assets (such as a JS library)
   * before the component is rendered.
   *
   * Note that a slow willStart method will slow down the rendering of the user
   * interface.  Therefore, some effort should be made to make this method as
   * fast as possible.
   *
   * Note: this method should not be called manually.
   */
  async willStart() {}

  /**
   * mounted is a hook that is called each time a component is attached to the
   * DOM. This is a good place to add some listeners, or to interact with the
   * DOM, if the component needs to perform some measure for example.
   *
   * Note: this method should not be called manually.
   *
   * @see willUnmount
   */
  mounted() {}

  /**
   * The willUpdateProps is an asynchronous hook, called just before new props
   * are set. This is useful if the component needs some asynchronous task
   * performed, depending on the props (for example, assuming that the props are
   * some record Id, fetching the record data).
   *
   * This hook is not called during the first render (but willStart is called
   * and performs a similar job).
   */
  async willUpdateProps(nextProps: Props) {}

  /**
   * The willPatch hook is called just before the DOM patching process starts.
   * It is not called on the initial render.  This is useful to get some
   * information which are in the DOM.  For example, the current position of the
   * scrollbar
   */
  willPatch(): any {}

  /**
   * This hook is called whenever a component did actually update its props,
   * state or env.
   *
   * This method is not called on the initial render. It is useful to interact
   * with the DOM (for example, through an external library) whenever the
   * component was updated.
   *
   * Updating the component state in this hook is possible, but not encouraged.
   * One need to be careful, because updates here will cause rerender, which in
   * turn will cause other calls to updated. So, we need to be particularly
   * careful at avoiding endless cycles.
   */
  patched() {}

  /**
   * willUnmount is a hook that is called each time just before a component is
   * unmounted from the DOM. This is a good place to remove some listeners, for
   * example.
   *
   * Note: this method should not be called manually.
   *
   * @see mounted
   */
  willUnmount() {}

  /**
   * catchError is a method called whenever some error happens in the rendering or
   * lifecycle hooks of a child.
   *
   * It needs to be implemented by a component that is designed to handle the
   * error properly.
   */
  catchError?(error?: Error): void;

  //--------------------------------------------------------------------------
  // Public
  //--------------------------------------------------------------------------

  /**
   * Mount the component to a target element.
   *
   * This should only be done if the component was created manually. Components
   * created declaratively in templates are managed by the Owl system.
   *
   * Note that a component can be mounted an unmounted several times
   */
  async mount(target: HTMLElement | DocumentFragment, options: MountOptions = {}): Promise<void> {
    if (!(target instanceof HTMLElement || target instanceof DocumentFragment)) {
      let message = `Component '${this.constructor.name}' cannot be mounted: the target is not a valid DOM node.`;
      message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`;
      throw new Error(message);
    }
    const position = options.position || "last-child";
    const __owl__ = this.__owl__;
    const currentFiber = __owl__.currentFiber;

    switch (__owl__.status) {
      case STATUS.CREATED: {
        const fiber = new Fiber(null, this, true, target, position);
        fiber.shouldPatch = false;
        this.__prepareAndRender(fiber, () => {});
        return scheduler.addFiber(fiber);
      }
      case STATUS.WILLSTARTED:
      case STATUS.RENDERED:
        currentFiber.target = target;
        currentFiber.position = position;
        return scheduler.addFiber(currentFiber);

      case STATUS.UNMOUNTED: {
        const fiber = new Fiber(null, this, true, target, position);
        fiber.shouldPatch = false;
        this.__render(fiber);
        return scheduler.addFiber(fiber);
      }

      case STATUS.MOUNTED: {
        if (position !== "self" && this.el!.parentNode !== target) {
          const fiber = new Fiber(null, this, true, target, position);
          fiber.shouldPatch = false;
          this.__render(fiber);
          return scheduler.addFiber(fiber);
        } else {
          return Promise.resolve();
        }
      }

      case STATUS.DESTROYED:
        throw new Error("Cannot mount a destroyed component");
    }
  }

  /**
   * The unmount method is the opposite of the mount method.  It is useful
   * to call willUnmount calls and remove the component from the DOM.
   */
  unmount() {
    if (this.__owl__.status === STATUS.MOUNTED) {
      this.__callWillUnmount();
      this.el!.remove();
    }
  }

  /**
   * The render method is the main entry point to render a component (once it
   * is ready. This method is not initially called when the component is
   * rendered the first time).
   *
   * This method will cause all its sub components to potentially rerender
   * themselves.  Note that `render` is not called if a component is updated via
   * its props.
   */
  async render(force: boolean = false): Promise<void> {
    const __owl__ = this.__owl__;
    const currentFiber = __owl__.currentFiber;
    if (!__owl__.vnode && !currentFiber) {
      return;
    }
    if (currentFiber && !currentFiber.isRendered && !currentFiber.isCompleted) {
      return scheduler.addFiber(currentFiber.root);
    }
    // if we aren't mounted at this point, it implies that there is a
    // currentFiber that is already rendered (isRendered is true), so we are
    // about to be mounted
    const status = __owl__.status;
    const fiber = new Fiber(null, this, force, null, null);
    Promise.resolve().then(() => {
      if (__owl__.status === STATUS.MOUNTED || status !== STATUS.MOUNTED) {
        if (fiber.isCompleted || fiber.isRendered) {
          return;
        }
        this.__render(fiber);
      } else {
        // we were mounted when render was called, but we aren't anymore, so we
        // were actually about to be unmounted ; we can thus forget about this
        // fiber
        fiber.isCompleted = true;
        __owl__.currentFiber = null;
      }
    });
    return scheduler.addFiber(fiber);
  }

  /**
   * Destroy the component.  This operation is quite complex:
   *  - it recursively destroy all children
   *  - call the willUnmount hooks if necessary
   *  - remove the dom node from the dom
   *
   * This should only be called manually if you created the component.  Most
   * components will be automatically destroyed.
   */
  destroy() {
    const __owl__ = this.__owl__;
    if (__owl__.status !== STATUS.DESTROYED) {
      const el = this.el;
      this.__destroy(__owl__.parent);
      if (el) {
        el.remove();
      }
    }
  }

  /**
   * This method is called by the component system whenever its props are
   * updated. If it returns true, then the component will be rendered.
   * Otherwise, it will skip the rendering (also, its props will not be updated)
   */
  shouldUpdate(nextProps: Props): boolean {
    return true;
  }

  /**
   * Emit a custom event of type 'eventType' with the given 'payload' on the
   * component's el, if it exists. However, note that the event will only bubble
   * up to the parent DOM nodes. Thus, it must be called between mounted() and
   * willUnmount().
   */
  trigger<T = any>(eventType: string, payload?: T) {
    this.__trigger<T>(this, eventType, payload);
  }

  //--------------------------------------------------------------------------
  // Private
  //--------------------------------------------------------------------------

  /**
   * Private helper to perform a full destroy, from the point of view of an Owl
   * component. It does not remove the el (this is done only once on the top
   * level destroyed component, for performance reasons).
   *
   * The job of this method is mostly to call willUnmount hooks, and to perform
   * all necessary internal cleanup.
   *
   * Note that it does not call the __callWillUnmount method to avoid visiting
   * all children many times.
   */
  __destroy(parent: Component | null) {
    const __owl__ = this.__owl__;
    if (__owl__.status === STATUS.MOUNTED) {
      if (__owl__.willUnmountCB) {
        __owl__.willUnmountCB();
      }
      this.willUnmount();
      __owl__.status = STATUS.UNMOUNTED;
    }
    const children = __owl__.children;
    for (let key in children) {
      children[key].__destroy(this);
    }
    if (parent) {
      let id = __owl__.id;
      delete parent.__owl__.children[id];
      __owl__.parent = null;
    }
    __owl__.status = STATUS.DESTROYED;
    delete __owl__.vnode;
    if (__owl__.currentFiber) {
      __owl__.currentFiber.isCompleted = true;
    }
  }

  __callMounted() {
    const __owl__ = this.__owl__;

    __owl__.status = STATUS.MOUNTED;
    __owl__.currentFiber = null;
    this.mounted();
    if (__owl__.mountedCB) {
      __owl__.mountedCB();
    }
  }

  __callWillUnmount() {
    const __owl__ = this.__owl__;
    if (__owl__.willUnmountCB) {
      __owl__.willUnmountCB();
    }
    this.willUnmount();
    __owl__.status = STATUS.UNMOUNTED;
    if (__owl__.currentFiber) {
      __owl__.currentFiber.isCompleted = true;
      __owl__.currentFiber.root.counter = 0;
    }
    const children = __owl__.children;
    for (let id in children) {
      const comp = children[id];
      if (comp.__owl__.status === STATUS.MOUNTED) {
        comp.__callWillUnmount();
      }
    }
  }
  /**
   * Private trigger method, allows to choose the component which triggered
   * the event in the first place
   */
  __trigger<T>(component: Component, eventType: string, payload?: T) {
    if (this.el) {
      const ev = new OwlEvent<T>(component, eventType, {
        bubbles: true,
        cancelable: true,
        detail: payload,
      });
      const triggerHook = this.env[portalSymbol as any];
      if (triggerHook) {
        triggerHook(ev);
      }
      this.el.dispatchEvent(ev);
    }
  }
  /**
   * The __updateProps method is called by the t-component directive whenever
   * it updates a component (so, when the parent template is rerendered).
   */
  async __updateProps(nextProps: Props, parentFiber: Fiber, scope: any): Promise<void> {
    this.__owl__.scope = scope;
    const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps);
    if (shouldUpdate) {
      const __owl__ = this.__owl__;
      const fiber = new Fiber(parentFiber, this, parentFiber.force, null, null);
      if (!parentFiber.child) {
        parentFiber.child = fiber;
      } else {
        parentFiber.lastChild!.sibling = fiber;
      }
      parentFiber.lastChild = fiber;

      const defaultProps = (<any>this.constructor).defaultProps;
      if (defaultProps) {
        this.__applyDefaultProps(nextProps, defaultProps);
      }
      if (QWeb.dev) {
        QWeb.utils.validateProps(this.constructor, nextProps);
      }
      await Promise.all([
        this.willUpdateProps(nextProps),
        __owl__.willUpdatePropsCB && __owl__.willUpdatePropsCB(nextProps),
      ]);
      if (fiber.isCompleted) {
        return;
      }
      this.props = nextProps;

      this.__render(fiber);
    }
  }

  /**
   * Main patching method. We call the virtual dom patch method here to convert
   * a virtual dom vnode into some actual dom.
   */
  __patch(target: HTMLElement | VNode | DocumentFragment, vnode: VNode) {
    this.__owl__.vnode = patch(target as any, vnode);
  }

  /**
   * The __prepare method is only called by the t-component directive, when a
   * subcomponent is created. It gets its scope, if any, from the
   * parent template.
   */
  __prepare(parentFiber: Fiber, scope: any, cb: CallableFunction): Fiber {
    this.__owl__.scope = scope;
    const fiber = new Fiber(parentFiber, this, parentFiber.force, null, null);
    fiber.shouldPatch = false;
    if (!parentFiber.child) {
      parentFiber.child = fiber;
    } else {
      parentFiber.lastChild!.sibling = fiber;
    }
    parentFiber.lastChild = fiber;
    this.__prepareAndRender(fiber, cb);
    return fiber;
  }

  /**
   * Apply the stylesheets defined by the component. Note that we need to make
   * sure all inherited stylesheets are applied as well.  We then delete the
   * `style` key from the constructor to make sure we do not apply it again.
   */
  private __applyStyles(constr) {
    while (constr && constr.style) {
      if (constr.hasOwnProperty("style")) {
        activateSheet(constr.style, constr.name);
        delete constr.style;
      }
      constr = constr.__proto__;
    }
  }
  __getTemplate(qweb: QWeb): string {
    let p = (<any>this).constructor;
    if (!p.hasOwnProperty("_template")) {
      // here, the component and none of its superclasses defines a static `template`
      // key. So we fall back on looking for a template matching its name (or
      // one of its subclass).

      let template: string = p.name;
      while (!(template in qweb.templates) && p !== Component) {
        p = p.__proto__;
        template = p.name;
      }
      if (p === Component) {
        throw new Error(`Could not find template for component "${this.constructor.name}"`);
      } else {
        p._template = template;
      }
    }
    return p._template;
  }

  async __prepareAndRender(fiber: Fiber, cb: CallableFunction) {
    try {
      const proms = Promise.all([
        this.willStart(),
        this.__owl__.willStartCB && this.__owl__.willStartCB(),
      ]);
      this.__owl__.status = STATUS.WILLSTARTED;
      await proms;
      if (this.__owl__.status === <any>STATUS.DESTROYED) {
        return Promise.resolve();
      }
    } catch (e) {
      fiber.handleError(e);
      return Promise.resolve();
    }
    if (!fiber.isCompleted) {
      this.__render(fiber);
      this.__owl__.status = STATUS.RENDERED;
      cb();
    }
  }

  __render(fiber: Fiber) {
    const __owl__ = this.__owl__;
    if (__owl__.observer) {
      __owl__.observer.allowMutations = false;
    }
    let error;
    try {
      let vnode = __owl__.renderFn!(this, {
        handlers: __owl__.boundHandlers,
        fiber: fiber,
      });
      // we iterate over the children to detect those that no longer belong to the
      // current rendering: those ones, if not mounted yet, can (and have to) be
      // destroyed right now, because they are not in the DOM, and thus we won't
      // be notified later on (when patching), that they are removed from the DOM
      for (let childKey in __owl__.children) {
        const child = __owl__.children[childKey];
        const childOwl = child.__owl__;
        if (childOwl.status !== STATUS.MOUNTED && childOwl.parentLastFiberId < fiber.id) {
          // we only do here a "soft" destroy, meaning that we leave the child
          // dom node alone, without removing it.  Most of the time, it does not
          // matter, because the child component is already unmounted.  However,
          // if some of its parent have been unmounted, the child could actually
          // still be attached to its parent, and this may be important if we
          // want to remount the parent, because the vdom need to match the
          // actual DOM
          child.__destroy(childOwl.parent);
          if (childOwl.pvnode) {
            // we remove the key here to make sure that the patching algorithm
            // is able to make the difference between this pvnode and an eventual
            // other instance of the same component
            delete childOwl.pvnode.key;
            // Since the component has been unmounted, we do not want to actually
            // call a remove hook.  This is pretty important, since the t-component
            // directive actually disabled it, so the vdom algorithm will just
            // not remove the child elm if we don't remove the hook.
            delete childOwl.pvnode.data!.hook!.remove;
          }
        }
      }
      if (!vnode) {
        throw new Error(`Rendering '${this.constructor.name}' did not return anything`);
      }
      fiber.vnode = vnode;
      // we apply here the class information described on the component by the
      // template (so, something like <MyComponent class="..."/>) to the actual
      // root vnode
      if (__owl__.classObj) {
        const data = vnode.data!;
        data.class = Object.assign(data.class || {}, __owl__.classObj);
      }
    } catch (e) {
      error = e;
    }
    if (__owl__.observer) {
      __owl__.observer.allowMutations = true;
    }

    fiber.root.counter--;
    fiber.isRendered = true;
    if (error) {
      fiber.handleError(error);
    }
  }

  /**
   * Apply default props (only top level).
   *
   * Note that this method does modify in place the props
   */
  __applyDefaultProps(props: Object, defaultProps: Object) {
    for (let propName in defaultProps) {
      if (props![propName] === undefined) {
        props![propName] = defaultProps[propName];
      }
    }
  }
}

interface MountParameters {
  env?: Env;
  target: HTMLElement | DocumentFragment;
  props?: any;
  position?: MountOptions["position"];
}

interface Type<T> extends Function {
  new (...args: any[]): T;
}

export async function mount<T extends Type<Component>>(
  C: T,
  params: MountParameters
): Promise<InstanceType<T>> {
  const { env, props, target } = params;
  let origEnv = C.hasOwnProperty("env") ? (C as any).env : null;
  if (env) {
    ((C as any) as typeof Component).env = env;
  }
  const component: Component = new C(null, props);
  if (origEnv) {
    (C as any).env = origEnv;
  } else {
    delete (C as any).env;
  }
  const position = params.position || "last-child";
  await component.mount(target, { position });
  return component as any;
}
