import { relativeToFile } from 'aurelia-path';
import { metadata } from 'aurelia-metadata';
import * as LogManager from 'aurelia-logging';
import { BindableProperty } from './bindable-property';
import { HtmlBehaviorResource } from './html-behavior';
import { BindingLanguage } from './binding-language';
import { BehaviorInstruction, ViewCompileInstruction, ViewCreateInstruction } from './instructions';
import { Container } from 'aurelia-dependency-injection';
import { _hyphenate } from './util';
import { ValueConverterResource, BindingBehaviorResource, camelCase, bindingMode } from 'aurelia-binding';
import { ViewEngineHooksResource } from './view-engine-hooks-resource';
import { ViewResourceType } from './type-extension';
import { ViewFactory } from './view-factory';
import { ViewCompiler } from './view-compiler';
import { View } from './view';

function register(lookup, name, resource, type) {
  if (!name) {
    return;
  }

  let existing = lookup[name];
  if (existing) {
    if (existing !== resource) {
      throw new Error(`Attempted to register ${type} when one with the same name already exists. Name: ${name}.`);
    }

    return;
  }

  lookup[name] = resource;
}

/**
* View engine hooks that enable a view resource to provide custom processing during the compilation or creation of a view.
*/
export interface ViewEngineHooks {
  /**
  * Invoked before a template is compiled.
  * @param content The DocumentFragment to compile.
  * @param resources The resources to compile the view against.
  * @param instruction The compilation instruction associated with the compilation process.
  */
  beforeCompile?: (content: DocumentFragment, resources: ViewResources, instruction: ViewCompileInstruction) => void;
  /**
  * Invoked after a template is compiled.
  * @param viewFactory The view factory that was produced from the compilation process.
  */
  afterCompile?: (viewFactory: ViewFactory) => void;
  /**
  * Invoked before a view is created.
  * @param viewFactory The view factory that will be used to create the view.
  * @param container The DI container used during view creation.
  * @param content The cloned document fragment representing the view.
  * @param instruction The view creation instruction associated with this creation process.
  */
  beforeCreate?: (viewFactory: ViewFactory, container: Container, content: DocumentFragment, instruction: ViewCreateInstruction) => void;
  /**
  * Invoked after a view is created.
  * @param view The view that was created by the factory.
  */
  afterCreate?: (view: View) => void;

  /**
  * Invoked after the bindingContext and overrideContext are configured on the view but before the view is bound.
  * @param view The view that was created by the factory.
  */
  beforeBind?: (view: View) => void;

  /**
  * Invoked before the view is unbind. The bindingContext and overrideContext are still available on the view.
  * @param view The view that was created by the factory.
  */
  beforeUnbind?: (view: View) => void;
}

export interface IBindablePropertyConfig {
  /**
  * The name of the property.
  */
  name?: string;
  attribute?: string;
  /**
   * The default binding mode of the property. If given string, will use to lookup
   */
  defaultBindingMode?: bindingMode | 'oneTime' | 'oneWay' | 'twoWay' | 'fromView' | 'toView';
  /**
   * The name of a view model method to invoke when the property is updated.
   */
  changeHandler?: string;
  /**
   * A default value for the property.
   */
  defaultValue?: any;
  /**
   * Designates the property as the default bindable property among all the other bindable properties when used in a custom attribute with multiple bindable properties.
   */
  primaryProperty?: boolean;
  // For compatibility and future extension
  [key: string]: any;
}

export type IStaticResource = Function & {
  $resource?: string | IStaticResourceConfig | (() => string | IStaticResourceConfig);
};

export interface IStaticResourceConfig {
  /**
   * Resource type of this class, omit equals to custom element
   */
  type?: 'element' | 'attribute' | 'valueConverter' | 'bindingBehavior' | 'viewEngineHooks';
  /**
   * Name of this resource. Reccommended to explicitly set to works better with minifier
   */
  name?: string;
  /**
   * Used to tell if a custom attribute is a template controller
   */
  templateController?: boolean;
  /**
   * Used to set default binding mode of default custom attribute view model "value" property
   */
  defaultBindingMode?: bindingMode | 'oneTime' | 'oneWay' | 'twoWay' | 'fromView' | 'toView';
  /**
   * Flags a custom attribute has dynamic options
   */
  hasDynamicOptions?: boolean;
  /**
   * Flag if this custom element uses native shadow dom instead of emulation
   */
  usesShadowDOM?: boolean;
  /**
   * Options that will be used if the element is flagged with usesShadowDOM
   */
  shadowDOMOptions?: ShadowRootInit;
  /**
   * Flag a custom element as containerless. Which will remove their render target
   */
  containerless?: boolean;
  /**
   * Custom processing of the attributes on an element before the framework inspects them.
   */
  processAttributes?: (viewCompiler: ViewCompiler, resources: ViewResources, node: Element, attributes: NamedNodeMap, elementInstruction: BehaviorInstruction) => void;
  /**
   * Enables custom processing of the content that is places inside the custom element by its consumer.
   * Pass a boolean to direct the template compiler to not process
   * the content placed inside this element. Alternatively, pass a function which
   * can provide custom processing of the content. This function should then return
   * a boolean indicating whether the compiler should also process the content.
   */
  processContent?: (viewCompiler: ViewCompiler, resources: ViewResources, node: Element, instruction: BehaviorInstruction) => boolean;
  /**
   * List of bindable properties of this custom element / custom attribute, by name or full config object
   */
  bindables?: Array<string | IBindablePropertyConfig>;
}

export function validateBehaviorName(name: string, type: string) {
  if (/[A-Z]/.test(name)) {
    let newName = _hyphenate(name);
    LogManager
      .getLogger('templating')
      .warn(`'${name}' is not a valid ${type} name and has been converted to '${newName}'. Upper-case letters are not allowed because the DOM is not case-sensitive.`);
    return newName;
  }
  return name;
}

const conventionMark = '__au_resource__';

/**
 * Represents a collection of resources used during the compilation of a view.
 * Will optinally add information to an existing HtmlBehaviorResource if given
 */
export class ViewResources {

  /** @internal */
  private parent: ViewResources;

  /** @internal */
  private hasParent: boolean;

  /** @internal */
  viewUrl: string;

  /** @internal */
  private lookupFunctions: { valueConverters: any; bindingBehaviors: any; };

  /** @internal */
  private attributes: any;

  /** @internal */
  private elements: any;

  /** @internal */
  private valueConverters: any;

  /** @internal */
  private bindingBehaviors: any;

  /** @internal */
  private attributeMap: any;

  /** @internal */
  private values: any;

  /** @internal */
  beforeCompile: boolean;
  /** @internal */
  afterCompile: boolean;
  /** @internal */
  beforeCreate: boolean;
  /** @internal */
  afterCreate: boolean;
  /** @internal */
  beforeBind: boolean;
  /** @internal */
  beforeUnbind: boolean;

  /**
   * Checks whether the provided class contains any resource conventions
   * @param target Target class to extract metadata based on convention
   * @param existing If supplied, all custom element / attribute metadata extracted from convention will be apply to this instance
   */
  static convention(target: IStaticResource, existing?: HtmlBehaviorResource): ViewResourceType {
    let resource;
    // Use a simple string to mark that an HtmlBehaviorResource instance
    // has been applied all resource information from its target view model class
    // to prevent subsequence call re initialization all info again
    if (existing && conventionMark in existing) {
      return existing;
    }
    if ('$resource' in target) {
      let config = target.$resource;
      // 1. check if resource config is a string
      if (typeof config === 'string') {
        // it's a custom element, with name is the resource variable
        // static $resource = 'my-element'
        resource = existing || new HtmlBehaviorResource();
        resource[conventionMark] = true;
        if (!resource.elementName) {
          // if element name was not specified before
          resource.elementName = validateBehaviorName(config, 'custom element');
        }
      } else {
        // 2. if static config is not a string, normalize into an config object
        if (typeof config === 'function') {
          // static $resource() {  }
          config = config.call(target);
        }
        if (typeof config === 'string') {
          // static $resource() { return 'my-custom-element-name' }
          // though rare case, still needs to handle properly
          config = { name: config };
        }
        // after normalization, copy to another obj
        // as the config could come from a static field, which subject to later reuse
        // it shouldn't be modified
        config = Object.assign({}, config);
        // no type specified = custom element
        let resourceType = config.type || 'element';
        // cannot do name = config.name || target.name
        // depends on resource type, it may need to use different strategies to normalize name
        let name = config.name;
        switch (resourceType) { // eslint-disable-line default-case
        case 'element': case 'attribute':
          // if a metadata is supplied, use it
          resource = existing || new HtmlBehaviorResource();
          resource[conventionMark] = true;
          if (resourceType === 'element') {
            // if element name was defined before applying convention here
            // it's a result from `@customElement` call (or manual modification)
            // need not to redefine name
            // otherwise, fall into following if
            if (!resource.elementName) {
              resource.elementName = name
                ? validateBehaviorName(name, 'custom element')
                : _hyphenate(target.name);
            }
          } else {
            // attribute name was defined before applying convention here
            // it's a result from `@customAttribute` call (or manual modification)
            // need not to redefine name
            // otherwise, fall into following if
            if (!resource.attributeName) {
              resource.attributeName = name
                ? validateBehaviorName(name, 'custom attribute')
                : _hyphenate(target.name);
            }
          }
          if ('templateController' in config) {
            // map templateController to liftsContent
            (config as any).liftsContent = config.templateController;
            delete config.templateController;
          }
          if ('defaultBindingMode' in config && resource.attributeDefaultBindingMode !== undefined) {
            // map defaultBindingMode to attributeDefaultBinding mode
            // custom element doesn't have default binding mode
            (config as any).attributeDefaultBindingMode = config.defaultBindingMode;
            delete config.defaultBindingMode;
          }
          // not bringing over the name.
          delete config.name;
          // just copy over. Devs are responsible for what specified in the config
          Object.assign(resource, config);
          break;
        case 'valueConverter':
          resource = new ValueConverterResource(camelCase(name || target.name));
          break;
        case 'bindingBehavior':
          resource = new BindingBehaviorResource(camelCase(name || target.name));
          break;
        case 'viewEngineHooks':
          resource = new ViewEngineHooksResource();
          break;
        }
      }

      if (resource instanceof HtmlBehaviorResource) {
        // check for bindable registration
        // This will concat bindables specified in static field / method with bindables specified via decorators
        // Which means if `name` is specified in both decorator and static config, it will be duplicated here
        // though it will finally resolves to only 1 `name` attribute
        // Will not break if it's done in that way but probably only happenned in inheritance scenarios.
        let bindables = typeof config === 'string' ? undefined : config.bindables;
        let currentProps = resource.properties;
        if (Array.isArray(bindables)) {
          for (let i = 0, ii = bindables.length; ii > i; ++i) {
            let prop = bindables[i];
            if (!prop || (typeof prop !== 'string' && !prop.name)) {
              throw new Error(`Invalid bindable property at "${i}" for class "${target.name}". Expected either a string or an object with "name" property.`);
            }
            let newProp = new BindableProperty(prop);
            // Bindable properties defined in $resource convention
            // shouldn't override existing prop with the same name
            // as they could be explicitly defined via decorator, thus more trust worthy ?
            let existed = false;
            for (let j = 0, jj = currentProps.length; jj > j; ++j) {
              if (currentProps[j].name === newProp.name) {
                existed = true;
                break;
              }
            }
            if (existed) {
              continue;
            }
            newProp.registerWith(target, resource);
          }
        }
      }
    }
    return resource;
  }

  /**
  * A custom binding language used in the view.
  */
  bindingLanguage = null;

  /**
  * Creates an instance of ViewResources.
  * @param parent The parent resources. This resources can override them, but if a resource is not found, it will be looked up in the parent.
  * @param viewUrl The url of the view to which these resources apply.
  */
  constructor(parent?: ViewResources, viewUrl?: string) {
    this.parent = parent || null;
    this.hasParent = this.parent !== null;
    this.viewUrl = viewUrl || '';
    this.lookupFunctions = {
      valueConverters: this.getValueConverter.bind(this),
      bindingBehaviors: this.getBindingBehavior.bind(this)
    };
    this.attributes = Object.create(null);
    this.elements = Object.create(null);
    this.valueConverters = Object.create(null);
    this.bindingBehaviors = Object.create(null);
    this.attributeMap = Object.create(null);
    this.values = Object.create(null);
    this.beforeCompile = this.afterCompile = this.beforeCreate = this.afterCreate = this.beforeBind = this.beforeUnbind = false;
  }

  /** @internal */
  _tryAddHook(obj, name) {
    if (typeof obj[name] === 'function') {
      let func = obj[name].bind(obj);
      let counter = 1;
      let callbackName;

      while (this[callbackName = name + counter.toString()] !== undefined) {
        counter++;
      }

      this[name] = true;
      this[callbackName] = func;
    }
  }

  /** @internal */
  _invokeHook(name: string, one?, two?, three?, four?) {
    if (this.hasParent) {
      this.parent._invokeHook(name, one, two, three, four);
    }

    if (this[name]) {
      this[name + '1'](one, two, three, four);

      let callbackName = name + '2';
      if (this[callbackName]) {
        this[callbackName](one, two, three, four);

        callbackName = name + '3';
        if (this[callbackName]) {
          this[callbackName](one, two, three, four);

          let counter = 4;

          while (this[callbackName = name + counter.toString()] !== undefined) {
            this[callbackName](one, two, three, four);
            counter++;
          }
        }
      }
    }
  }

  /**
  * Registers view engine hooks for the view.
  * @param hooks The hooks to register.
  */
  registerViewEngineHooks(hooks: ViewEngineHooks): void {
    this._tryAddHook(hooks, 'beforeCompile');
    this._tryAddHook(hooks, 'afterCompile');
    this._tryAddHook(hooks, 'beforeCreate');
    this._tryAddHook(hooks, 'afterCreate');
    this._tryAddHook(hooks, 'beforeBind');
    this._tryAddHook(hooks, 'beforeUnbind');
  }

  /**
  * Gets the binding language associated with these resources, or return the provided fallback implementation.
  * @param bindingLanguageFallback The fallback binding language implementation to use if no binding language is configured locally.
  * @return The binding language.
  */
  getBindingLanguage(bindingLanguageFallback: BindingLanguage): BindingLanguage {
    return this.bindingLanguage || (this.bindingLanguage = bindingLanguageFallback);
  }

  /**
  * Patches an immediate parent into the view resource resolution hierarchy.
  * @param newParent The new parent resources to patch in.
  */
  patchInParent(newParent: ViewResources): void {
    let originalParent = this.parent;

    this.parent = newParent || null;
    this.hasParent = this.parent !== null;

    if (newParent.parent === null) {
      newParent.parent = originalParent;
      newParent.hasParent = originalParent !== null;
    }
  }

  /**
  * Maps a path relative to the associated view's origin.
  * @param path The relative path.
  * @return The calcualted path.
  */
  relativeToView(path: string): string {
    return relativeToFile(path, this.viewUrl);
  }

  /**
  * Registers an HTML element.
  * @param tagName The name of the custom element.
  * @param behavior The behavior of the element.
  */
  registerElement(tagName: string, behavior: HtmlBehaviorResource): void {
    register(this.elements, tagName, behavior, 'an Element');
  }

  /**
  * Gets an HTML element behavior.
  * @param tagName The tag name to search for.
  * @return The HtmlBehaviorResource for the tag name or null.
  */
  getElement(tagName: string): HtmlBehaviorResource {
    return this.elements[tagName] || (this.hasParent ? this.parent.getElement(tagName) : null);
  }

  /**
  * Gets the known attribute name based on the local attribute name.
  * @param attribute The local attribute name to lookup.
  * @return The known name.
  */
  mapAttribute(attribute: string): string {
    return this.attributeMap[attribute] || (this.hasParent ? this.parent.mapAttribute(attribute) : null);
  }

  /**
  * Registers an HTML attribute.
  * @param attribute The name of the attribute.
  * @param behavior The behavior of the attribute.
  * @param knownAttribute The well-known name of the attribute (in lieu of the local name).
  */
  registerAttribute(attribute: string, behavior: HtmlBehaviorResource, knownAttribute: string): void {
    this.attributeMap[attribute] = knownAttribute;
    register(this.attributes, attribute, behavior, 'an Attribute');
  }

  /**
  * Gets an HTML attribute behavior.
  * @param attribute The name of the attribute to lookup.
  * @return The HtmlBehaviorResource for the attribute or null.
  */
  getAttribute(attribute: string): HtmlBehaviorResource {
    return this.attributes[attribute] || (this.hasParent ? this.parent.getAttribute(attribute) : null);
  }

  /**
  * Registers a value converter.
  * @param name The name of the value converter.
  * @param valueConverter The value converter instance.
  */
  registerValueConverter(name: string, valueConverter: Object): void {
    register(this.valueConverters, name, valueConverter, 'a ValueConverter');
  }

  /**
  * Gets a value converter.
  * @param name The name of the value converter.
  * @return The value converter instance.
  */
  getValueConverter(name: string): Object {
    return this.valueConverters[name] || (this.hasParent ? this.parent.getValueConverter(name) : null);
  }

  /**
  * Registers a binding behavior.
  * @param name The name of the binding behavior.
  * @param bindingBehavior The binding behavior instance.
  */
  registerBindingBehavior(name: string, bindingBehavior: Object): void {
    register(this.bindingBehaviors, name, bindingBehavior, 'a BindingBehavior');
  }

  /**
  * Gets a binding behavior.
  * @param name The name of the binding behavior.
  * @return The binding behavior instance.
  */
  getBindingBehavior(name: string): Object {
    return this.bindingBehaviors[name] || (this.hasParent ? this.parent.getBindingBehavior(name) : null);
  }

  /**
  * Registers a value.
  * @param name The name of the value.
  * @param value The value.
  */
  registerValue(name: string, value: any): void {
    register(this.values, name, value, 'a value');
  }

  /**
  * Gets a value.
  * @param name The name of the value.
  * @return The value.
  */
  getValue(name: string): any {
    return this.values[name] || (this.hasParent ? this.parent.getValue(name) : null);
  }

  /**
   * Not supported for public use. Can be changed without warning.
   *
   * Auto register a resources based on its metadata or convention
   * Will fallback to custom element if no metadata found and all conventions fail
   * @param container
   * @param impl
   */
  autoRegister(container: Container, impl: Function): ViewResourceType {
    let resourceTypeMeta = metadata.getOwn(metadata.resource, impl) as ViewResourceType;
    if (resourceTypeMeta) {
      if (resourceTypeMeta instanceof HtmlBehaviorResource) {
        // first use static resource
        ViewResources.convention(impl, resourceTypeMeta);

        // then fallback to traditional convention
        if (resourceTypeMeta.attributeName === null && resourceTypeMeta.elementName === null) {
          //no customeElement or customAttribute but behavior added by other metadata
          HtmlBehaviorResource.convention(impl.name, resourceTypeMeta);
        }
        if (resourceTypeMeta.attributeName === null && resourceTypeMeta.elementName === null) {
          //no convention and no customeElement or customAttribute but behavior added by other metadata
          resourceTypeMeta.elementName = _hyphenate(impl.name);
        }
      }
    } else {
      resourceTypeMeta = ViewResources.convention(impl)
        || HtmlBehaviorResource.convention(impl.name)
        || ValueConverterResource.convention(impl.name)
        || BindingBehaviorResource.convention(impl.name)
        || ViewEngineHooksResource.convention(impl.name);
      if (!resourceTypeMeta) {
        // doesn't match any convention, and is an exported value => custom element
        resourceTypeMeta = new HtmlBehaviorResource();
        (resourceTypeMeta as HtmlBehaviorResource).elementName = _hyphenate(impl.name);
      }
      metadata.define(metadata.resource, resourceTypeMeta, impl);
    }
    resourceTypeMeta.initialize(container, impl);
    resourceTypeMeta.register(this, undefined);
    return resourceTypeMeta;
  }
}
