import {metadata, Origin} from 'aurelia-metadata';
import {ObserverLocator, Binding} from 'aurelia-binding';
import {TaskQueue} from 'aurelia-task-queue';
import {Container} from 'aurelia-dependency-injection';
import {ViewLocator} from './view-locator';
import {ViewEngine} from './view-engine';
import {ViewCompiler} from './view-compiler';
import {_hyphenate, _isAllWhitespace} from './util';
import {BindableProperty} from './bindable-property';
import {Controller} from './controller';
import {ViewResources} from './view-resources';
import {ResourceLoadContext, ViewCompileInstruction, BehaviorInstruction} from './instructions';
import {FEATURE, DOM} from 'aurelia-pal';
import { ViewStrategy } from './view-strategy';
import { ConstructableResourceTarget, ProcessAttributeCallback, ProcessContentCallback } from './type-extension';
import { ViewFactory } from './view-factory';

let lastProviderId = 0;

function nextProviderId() {
  return ++lastProviderId;
}

function doProcessContent() { return true; }
function doProcessAttributes() {}

/**
* Identifies a class as a resource that implements custom element or custom
* attribute functionality.
*/
export class HtmlBehaviorResource {

  /** @internal */
  properties: BindableProperty[];

  /** @internal */
  attributeName: string;

  /** @internal */
  elementName: string;

  /** @internal */
  attributeDefaultBindingMode: any;

  /** @internal */
  liftsContent: boolean;

  /** @internal */
  targetShadowDOM: boolean;

  /** @internal */
  shadowDOMOptions: ShadowRootInit;

  /** @internal */
  processAttributes: ProcessAttributeCallback;

  /** @internal */
  processContent: ProcessContentCallback;

  /** @internal */
  usesShadowDOM: boolean;

  /**
   * Child binding *expressions* associated with this behavior
   * @internal
   */
  private childBindings: any;

  /** @internal */
  hasDynamicOptions: boolean;

  /** @internal */
  containerless: boolean;

  /** @internal */
  attributes: Record<string, BindableProperty>;

  /** @internal */
  isInitialized: boolean;

  /** @internal */
  primaryProperty: BindableProperty;

  /** @internal */
  observerLocator: ObserverLocator;

  /** @internal */
  private taskQueue: TaskQueue;

  /** @internal */
  target: Function;

  /** @internal */
  handlesCreated: boolean;

  /** @internal */
  handlesBind: boolean;

  /** @internal */
  handlesUnbind: boolean;

  /** @internal */
  handlesAttached: boolean;

  /** @internal */
  handlesDetached: boolean;

  /** @internal */
  private htmlName: string;

  /** @internal */
  private viewStrategy: ViewStrategy;

  /** @internal */
  private viewFactory: ViewFactory;
  /**
  * Creates an instance of HtmlBehaviorResource.
  */
  constructor() {
    this.elementName = null;
    this.attributeName = null;
    this.attributeDefaultBindingMode = undefined;
    this.liftsContent = false;
    this.targetShadowDOM = false;
    this.shadowDOMOptions = null;
    this.processAttributes = doProcessAttributes;
    this.processContent = doProcessContent;
    this.usesShadowDOM = false;
    this.childBindings = null;
    this.hasDynamicOptions = false;
    this.containerless = false;
    this.properties = [];
    this.attributes = {};
    this.isInitialized = false;
    this.primaryProperty = null;
  }

  /**
  * Checks whether the provided name matches any naming conventions for HtmlBehaviorResource.
  * @param name The name of the potential resource.
  * @param existing An already existing resource that may need a convention name applied.
  */
  static convention(name: string, existing?: HtmlBehaviorResource): HtmlBehaviorResource {
    let behavior;

    if (name.endsWith('CustomAttribute')) {
      behavior = existing || new HtmlBehaviorResource();
      behavior.attributeName = _hyphenate(name.substring(0, name.length - 15));
    }

    if (name.endsWith('CustomElement')) {
      behavior = existing || new HtmlBehaviorResource();
      behavior.elementName = _hyphenate(name.substring(0, name.length - 13));
    }

    return behavior;
  }

  /**
  * Adds a binding expression to the component created by this resource.
  * @param behavior The binding expression.
  */
  addChildBinding(behavior: Object): void {
    if (this.childBindings === null) {
      this.childBindings = [];
    }

    this.childBindings.push(behavior);
  }

  /**
  * Provides an opportunity for the resource to initialize iteself.
  * @param container The dependency injection container from which the resource
  * can aquire needed services.
  * @param target The class to which this resource metadata is attached.
  */
  initialize(container: Container, target: Function): void {
    let proto = target.prototype;
    let properties = this.properties;
    let attributeName = this.attributeName;
    let attributeDefaultBindingMode = this.attributeDefaultBindingMode;
    let i;
    let ii;
    let current: BindableProperty;

    if (this.isInitialized) {
      return;
    }

    this.isInitialized = true;
    (target as ConstructableResourceTarget).__providerId__ = nextProviderId();

    this.observerLocator = container.get(ObserverLocator);
    this.taskQueue = container.get(TaskQueue);

    this.target = target;
    this.usesShadowDOM = this.targetShadowDOM && FEATURE.shadowDOM;
    this.handlesCreated = ('created' in proto);
    this.handlesBind = ('bind' in proto);
    this.handlesUnbind = ('unbind' in proto);
    this.handlesAttached = ('attached' in proto);
    this.handlesDetached = ('detached' in proto);
    this.htmlName = this.elementName || this.attributeName;

    if (attributeName !== null) {
      if (properties.length === 0) { //default for custom attributes
        new BindableProperty({
          name: 'value',
          changeHandler: 'valueChanged' in proto ? 'valueChanged' : null,
          attribute: attributeName,
          defaultBindingMode: attributeDefaultBindingMode
        }).registerWith(target, this);
      }

      current = properties[0];

      if (properties.length === 1 && current.name === 'value') { //default for custom attributes
        current.isDynamic = current.hasOptions = this.hasDynamicOptions;
        current.defineOn(target, this);
      } else { //custom attribute with options
        for (i = 0, ii = properties.length; i < ii; ++i) {
          properties[i].defineOn(target, this);
          if (properties[i].primaryProperty) {
            if (this.primaryProperty) {
              throw new Error('Only one bindable property on a custom element can be defined as the default');
            }
            this.primaryProperty = properties[i];
          }
        }

        current = new BindableProperty({
          name: 'value',
          changeHandler: 'valueChanged' in proto ? 'valueChanged' : null,
          attribute: attributeName,
          defaultBindingMode: attributeDefaultBindingMode
        });

        current.hasOptions = true;
        current.registerWith(target, this);
      }
    } else {
      for (i = 0, ii = properties.length; i < ii; ++i) {
        properties[i].defineOn(target, this);
      }
      // Because how inherited properties would interact with the default 'value' property
      // in a custom attribute is not well defined yet, we only inherit properties on
      // custom elements, where it's not a problem.
      this._copyInheritedProperties(container, target);
    }
  }

  /**
  * Allows the resource to be registered in the view resources for the particular
  * view into which it was required.
  * @param registry The view resource registry for the view that required this resource.
  * @param name The name provided by the end user for this resource, within the
  * particular view it's being used.
  */
  register(registry: ViewResources, name?: string): void {
    if (this.attributeName !== null) {
      registry.registerAttribute(name || this.attributeName, this, this.attributeName);

      if (Array.isArray(this.aliases)) {
        this.aliases
          // todo: this is pretty weird
          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
          // @ts-ignore
          .forEach( (alias) => {
            registry.registerAttribute(alias, this, this.attributeName);
          });
      }
    }

    if (this.elementName !== null) {
      registry.registerElement(name || this.elementName, this);
    }
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  aliases(aliases: any) {
    throw new Error('Method not implemented.');
  }

  /**
  * Enables the resource to asynchronously load additional resources.
  * @param container The dependency injection container from which the resource
  * can aquire needed services.
  * @param target The class to which this resource metadata is attached.
  * @param loadContext The loading context object provided by the view engine.
  * @param viewStrategy A view strategy to overload the default strategy defined by the resource.
  * @param transientView Indicated whether the view strategy is transient or
  * permanently tied to this component.
  */
  load(container: Container, target: Function, loadContext?: ResourceLoadContext, viewStrategy?: ViewStrategy, transientView?: boolean): Promise<HtmlBehaviorResource | ViewFactory> {
    let options;

    if (this.elementName !== null) {
      viewStrategy = container.get(ViewLocator).getViewStrategy(viewStrategy || this.viewStrategy || target);
      options = new ViewCompileInstruction(this.targetShadowDOM, true);

      if (!viewStrategy.moduleId) {
        viewStrategy.moduleId = Origin.get(target).moduleId;
      }

      return viewStrategy
        .loadViewFactory(container.get(ViewEngine), options, loadContext, target)
        .then(viewFactory => {
          if (!transientView || !this.viewFactory) {
            this.viewFactory = viewFactory;
          }

          return viewFactory;
        });
    }

    return Promise.resolve(this);
  }

  /**
  * Plugs into the compiler and enables custom processing of the node on which this behavior is located.
  * @param compiler The compiler that is currently compiling the view that this behavior exists within.
  * @param resources The resources for the view that this behavior exists within.
  * @param node The node on which this behavior exists.
  * @param instruction The behavior instruction created for this behavior.
  * @param parentNode The parent node of the current node.
  * @return The current node.
  */
  compile(compiler: ViewCompiler, resources: ViewResources, node: Element, instruction: BehaviorInstruction, parentNode?: Node): Element {
    if (this.liftsContent) {
      if (!instruction.viewFactory) {
        let template = DOM.createElement('template');
        let fragment = DOM.createDocumentFragment();
        let cacheSize = node.getAttribute('view-cache');
        let part = node.getAttribute('part');

        node.removeAttribute(instruction.originalAttrName);
        DOM.replaceNode(template, node, parentNode);
        fragment.appendChild(node);
        instruction.viewFactory = compiler.compile(fragment, resources);

        if (part) {
          instruction.viewFactory.part = part;
          node.removeAttribute('part');
        }

        if (cacheSize) {
          instruction.viewFactory.setCacheSize(cacheSize);
          node.removeAttribute('view-cache');
        }

        node = template;
      }
    } else if (this.elementName !== null) { //custom element
      let partReplacements = {};

      if (this.processContent(compiler, resources, node, instruction) && node.hasChildNodes()) {
        let currentChild = node.firstChild as Element;
        let contentElement = this.usesShadowDOM ? null : DOM.createElement('au-content');
        let nextSibling;
        let toReplace: string;

        while (currentChild) {
          nextSibling = currentChild.nextSibling;

          if (currentChild.tagName === 'TEMPLATE' && (toReplace = currentChild.getAttribute('replace-part'))) {
            partReplacements[toReplace] = compiler.compile(currentChild, resources);
            DOM.removeNode(currentChild, parentNode);
            instruction.partReplacements = partReplacements;
          } else if (contentElement !== null) {
            if (currentChild.nodeType === 3 && _isAllWhitespace(currentChild)) {
              DOM.removeNode(currentChild, parentNode);
            } else {
              contentElement.appendChild(currentChild);
            }
          }

          currentChild = nextSibling;
        }

        if (contentElement !== null && contentElement.hasChildNodes()) {
          node.appendChild(contentElement);
        }

        instruction.skipContentProcessing = false;
      } else {
        instruction.skipContentProcessing = true;
      }
    } else if (!this.processContent(compiler, resources, node, instruction)) {
      instruction.skipContentProcessing = true;
    }

    return node;
  }

  /**
  * Creates an instance of this behavior.
  * @param container The DI container to create the instance in.
  * @param instruction The instruction for this behavior that was constructed during compilation.
  * @param element The element on which this behavior exists.
  * @param bindings The bindings that are associated with the view in which this behavior exists.
  * @return The Controller of this behavior.
  */
  create(container: Container, instruction?: BehaviorInstruction, element?: Element, bindings?: Binding[]): Controller {
    let viewHost;
    let au = null;

    instruction = instruction || BehaviorInstruction.normal;
    element = element || null;
    bindings = bindings || null;

    if (this.elementName !== null && element) {
      if (this.usesShadowDOM) {
        viewHost = element.attachShadow(this.shadowDOMOptions);
        container.registerInstance(DOM.boundary, viewHost);
      } else {
        viewHost = element;
        if (this.targetShadowDOM) {
          container.registerInstance(DOM.boundary, viewHost);
        }
      }
    }

    if (element !== null) {
      element.au = au = element.au || {};
    }

    let viewModel = instruction.viewModel || container.get(this.target);
    let controller = new Controller(this, instruction, viewModel, container);
    let childBindings = this.childBindings;
    let viewFactory;

    if (this.liftsContent) {
      //template controller
      au.controller = controller;
    } else if (this.elementName !== null) {
      //custom element
      viewFactory = instruction.viewFactory || this.viewFactory;
      container.viewModel = viewModel;

      if (viewFactory) {
        controller.view = viewFactory.create(container, instruction, element);
      }

      if (element !== null) {
        au.controller = controller;

        if (controller.view) {
          if (!this.usesShadowDOM && (element.childNodes.length === 1 || element.contentElement)) { //containerless passes content view special contentElement property
            let contentElement = element.childNodes[0] || element.contentElement;
            controller.view.contentView = { fragment: contentElement }; //store the content before appending the view
            contentElement.parentNode && DOM.removeNode(contentElement); //containerless content element has no parent
          }

          if (instruction.anchorIsContainer) {
            if (childBindings !== null) {
              for (let i = 0, ii = childBindings.length; i < ii; ++i) {
                controller.view.addBinding(childBindings[i].create(element, viewModel, controller));
              }
            }

            controller.view.appendNodesTo(viewHost);
          } else {
            controller.view.insertNodesBefore(viewHost);
          }
        } else if (childBindings !== null) {
          for (let i = 0, ii = childBindings.length; i < ii; ++i) {
            bindings.push(childBindings[i].create(element, viewModel, controller));
          }
        }
      } else if (controller.view) {
        //dynamic element with view
        controller.view.controller = controller;

        if (childBindings !== null) {
          for (let i = 0, ii = childBindings.length; i < ii; ++i) {
            controller.view.addBinding(childBindings[i].create(instruction.host, viewModel, controller));
          }
        }
      } else if (childBindings !== null) {
        //dynamic element without view
        for (let i = 0, ii = childBindings.length; i < ii; ++i) {
          bindings.push(childBindings[i].create(instruction.host, viewModel, controller));
        }
      }
    } else if (childBindings !== null) {
      //custom attribute
      for (let i = 0, ii = childBindings.length; i < ii; ++i) {
        bindings.push(childBindings[i].create(element, viewModel, controller));
      }
    }

    if (au !== null) {
      au[this.htmlName] = controller;
    }

    if (instruction.initiatedByBehavior && viewFactory) {
      controller.view.created();
    }

    return controller;
  }

  /** @internal */
  _ensurePropertiesDefined(instance: Object, lookup: Object) {
    let properties;
    let i;
    let ii;
    let observer;

    if ('__propertiesDefined__' in lookup) {
      return;
    }

    (lookup as any).__propertiesDefined__ = true;
    properties = this.properties;

    for (i = 0, ii = properties.length; i < ii; ++i) {
      observer = properties[i].createObserver(instance);

      if (observer !== undefined) {
        lookup[observer.propertyName] = observer;
      }
    }
  }

  /** @internal */
  _copyInheritedProperties(container: Container, target: Function) {
    // This methods enables inherited @bindable properties.
    // We look for the first base class with metadata, make sure it's initialized
    // and copy its properties.
    // We don't need to walk further than the first parent with metadata because
    // it had also inherited properties during its own initialization.
    let behavior: HtmlBehaviorResource;
    let derived = target;

    // eslint-disable-next-line no-constant-condition
    while (true) {
      let proto = Object.getPrototypeOf(target.prototype);
      target = proto && proto.constructor;
      if (!target) {
        return;
      }
      behavior = metadata.getOwn(metadata.resource, target) as HtmlBehaviorResource;
      if (behavior) {
        break;
      }
    }
    behavior.initialize(container, target);
    for (let i = 0, ii = behavior.properties.length; i < ii; ++i) {
      let prop = behavior.properties[i];
      // Check that the property metadata was not overriden or re-defined in this class
      if (this.properties.some(p => p.name === prop.name)) {
        continue;
      }
      // We don't need to call .defineOn() for those properties because it was done
      // on the parent prototype during initialization.
      new BindableProperty(prop).registerWith(derived, this);
    }
  }
}

declare global {
  /** @internal */
  interface Element {
    au?: Record<string, Controller>;
    /**
     * a special property for containerless element
     */
    contentElement?: Element;
  }
}

/** @internal */
declare module 'aurelia-dependency-injection' {
  interface Container {
    viewModel: any;
  }
}
