import {_hyphenate} from './util';
import {BehaviorPropertyObserver} from './behavior-property-observer';
import {Binding, BindingExpression, bindingMode} from 'aurelia-binding';
import {Container} from 'aurelia-dependency-injection';
import {metadata} from 'aurelia-metadata';
import { HtmlBehaviorResource } from './html-behavior';
import { ComponentPropertyChanged, ObservableGetterFunction } from './interfaces';

function getObserver(instance, name) {
  let lookup = instance.__observers__;

  if (lookup === undefined) {
    // We need to lookup the actual behavior for this instance,
    // as it might be a derived class (and behavior) rather than
    // the class (and behavior) that declared the property calling getObserver().
    // This means we can't capture the behavior in property get/set/getObserver and pass it here.
    // Note that it's probably for the best, as passing the behavior is an overhead
    // that is only useful in the very first call of the first property of the instance.
    let ctor = Object.getPrototypeOf(instance).constructor; // Playing safe here, user could have written to instance.constructor.
    let behavior = metadata.get(metadata.resource, ctor) as HtmlBehaviorResource;
    if (!behavior.isInitialized) {
      behavior.initialize(Container.instance || new Container(), instance.constructor);
    }

    lookup = behavior.observerLocator.getOrCreateObserversLookup(instance);
    behavior._ensurePropertiesDefined(instance, lookup);
  }

  return lookup[name];
}

/**
* Represents a bindable property on a behavior.
*/
export class BindableProperty {
  name: any;

  /** @internal */
  attribute: any;

  /** @internal */
  defaultBindingMode: any;

  /** @internal */
  changeHandler: string;

  /** @internal */
  owner: any;

  /** @internal */
  descriptor: any;

  /** @internal */
  defaultValue: any;

  /** @internal */
  hasOptions: any;

  /** @internal */
  isDynamic: any;

  /**
   * On a custom attribute, `true` indicates this is the primary bindable prop
   * @internal
   */
  primaryProperty: boolean;

  /**
  * Creates an instance of BindableProperty.
  * @param nameOrConfig The name of the property or a cofiguration object.
  */
  constructor(nameOrConfig: string | Object) {
    if (typeof nameOrConfig === 'string') {
      this.name = nameOrConfig;
    } else {
      Object.assign(this, nameOrConfig);
    }

    this.attribute = this.attribute || _hyphenate(this.name);
    let defaultBindingMode = this.defaultBindingMode;
    if (defaultBindingMode === null || defaultBindingMode === undefined) {
      this.defaultBindingMode = bindingMode.oneWay;
    } else if (typeof defaultBindingMode === 'string') {
      // to avoid import from aurelia
      this.defaultBindingMode = bindingMode[defaultBindingMode] || bindingMode.oneWay;
    }
    this.changeHandler = this.changeHandler || null;
    this.owner = null;
    this.descriptor = null;
  }

  /**
  * Registers this bindable property with particular Class and Behavior instance.
  * @param target The class to register this behavior with.
  * @param behavior The behavior instance to register this property with.
  * @param descriptor The property descriptor for this property.
  */
  registerWith(target: Function, behavior: HtmlBehaviorResource, descriptor?: Object): void {
    behavior.properties.push(this);
    behavior.attributes[this.attribute] = this;
    this.owner = behavior;

    if (descriptor) {
      this.descriptor = descriptor;
      return this._configureDescriptor(descriptor as any) as unknown as void;
    }

    return undefined;
  }

  /** @internal */
  _configureDescriptor(descriptor: PropertyDescriptor & { initializer: any }): Object {
    let name = this.name;

    descriptor.configurable = true;
    descriptor.enumerable = true;

    if ('initializer' in descriptor) {
      this.defaultValue = descriptor.initializer;
      delete descriptor.initializer;
      delete descriptor.writable;
    }

    if ('value' in descriptor) {
      this.defaultValue = descriptor.value;
      delete descriptor.value;
      delete descriptor.writable;
    }

    descriptor.get = function() {
      return getObserver(this, name).getValue();
    };

    descriptor.set = function(value) {
      getObserver(this, name).setValue(value);
    };

    (descriptor.get as ObservableGetterFunction).getObserver = function(obj) {
      return getObserver(obj, name);
    };

    return descriptor;
  }

  /**
  * Defines this property on the specified class and behavior.
  * @param target The class to define the property on.
  * @param behavior The behavior to define the property on.
  */
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  defineOn(target: Function, behavior: HtmlBehaviorResource): void {
    let name = this.name;
    let handlerName;

    if (this.changeHandler === null) {
      handlerName = name + 'Changed';
      if (handlerName in target.prototype) {
        this.changeHandler = handlerName;
      }
    }

    if (this.descriptor === null) {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore todo: weird code here
      Object.defineProperty(target.prototype, name, this._configureDescriptor({}));
    }
  }

  /**
  * Creates an observer for this property.
  * @param viewModel The view model instance on which to create the observer.
  * @return The property observer.
  */
  createObserver(viewModel: Object): BehaviorPropertyObserver {
    let selfSubscriber = null;
    let defaultValue = this.defaultValue;
    let changeHandlerName = this.changeHandler;
    let name = this.name;
    let initialValue;

    if (this.hasOptions) {
      return undefined;
    }

    if (changeHandlerName in viewModel) {
      if ('propertyChanged' in viewModel) {
        selfSubscriber = (newValue, oldValue) => {
          viewModel[changeHandlerName](newValue, oldValue);
          (viewModel as ComponentPropertyChanged).propertyChanged(name, newValue, oldValue);
        };
      } else {
        selfSubscriber = (newValue, oldValue) => viewModel[changeHandlerName](newValue, oldValue);
      }
    } else if ('propertyChanged' in viewModel) {
      selfSubscriber = (newValue, oldValue) => (viewModel as ComponentPropertyChanged).propertyChanged(name, newValue, oldValue);
    } else if (changeHandlerName !== null) {
      throw new Error(`Change handler ${changeHandlerName} was specified but not declared on the class.`);
    }

    if (defaultValue !== undefined) {
      initialValue = typeof defaultValue === 'function' ? defaultValue.call(viewModel) : defaultValue;
    }

    return new BehaviorPropertyObserver(this.owner.taskQueue, viewModel, this.name, selfSubscriber, initialValue);
  }

  /** @internal */
  _initialize(viewModel: object, observerLookup: Record<string, any>, attributes: Record<string, string | BindingExpression>, behaviorHandlesBind?: boolean, boundProperties?: BoundPropertyInfo[]): void {
    let selfSubscriber: Function;
    let observer: BehaviorPropertyObserver;
    let attribute: string | BindingExpression;
    let defaultValue = this.defaultValue;

    if (this.isDynamic) {
      for (let key in attributes) {
        this._createDynamicProperty(viewModel, observerLookup, behaviorHandlesBind, key, attributes[key], boundProperties);
      }
    } else if (!this.hasOptions) {
      observer = observerLookup[this.name];

      if (attributes !== null) {
        selfSubscriber = observer.selfSubscriber;
        attribute = attributes[this.attribute];

        if (behaviorHandlesBind) {
          observer.selfSubscriber = null;
        }

        if (typeof attribute === 'string') {
          viewModel[this.name] = attribute;
          observer.call();
        } else if (attribute) {
          boundProperties.push({observer: observer, binding: attribute.createBinding(viewModel)});
        } else if (defaultValue !== undefined) {
          observer.call();
        }

        observer.selfSubscriber = selfSubscriber;
      }

      observer.publishing = true;
    }
  }

  /** @internal */
  _createDynamicProperty(viewModel, observerLookup, behaviorHandlesBind: boolean, name: string, attribute: string | BindingExpression, boundProperties: BoundPropertyInfo[]) {
    let changeHandlerName = name + 'Changed';
    let selfSubscriber = null;
    let observer: BehaviorPropertyObserver;
    let info: BoundPropertyInfo;

    if (changeHandlerName in viewModel) {
      if ('propertyChanged' in viewModel) {
        selfSubscriber = (newValue, oldValue) => {
          viewModel[changeHandlerName](newValue, oldValue);
          viewModel.propertyChanged(name, newValue, oldValue);
        };
      } else {
        selfSubscriber = (newValue, oldValue) => viewModel[changeHandlerName](newValue, oldValue);
      }
    } else if ('propertyChanged' in viewModel) {
      selfSubscriber = (newValue, oldValue) => viewModel.propertyChanged(name, newValue, oldValue);
    }

    observer = observerLookup[name] = new BehaviorPropertyObserver(
      this.owner.taskQueue,
      viewModel,
      name,
      selfSubscriber
    );

    Object.defineProperty(viewModel, name, {
      configurable: true,
      enumerable: true,
      get: observer.getValue.bind(observer),
      set: observer.setValue.bind(observer)
    });

    if (behaviorHandlesBind) {
      observer.selfSubscriber = null;
    }

    if (typeof attribute === 'string') {
      viewModel[name] = attribute;
      observer.call();
    } else if (attribute) {
      info = {observer: observer, binding: attribute.createBinding(viewModel)};
      boundProperties.push(info);
    }

    observer.publishing = true;
    observer.selfSubscriber = selfSubscriber;
  }
}

export interface BoundPropertyInfo {
  observer: BehaviorPropertyObserver;
  binding: Binding;
}
