import {
  Meta,
  InnerReferenceFactory,
  PropertyReference
} from '@glimmer/object-reference';
import { Dict, dict, assign, initializeGuid } from '@glimmer/util';
import {
  Mixin,
  extend as extendClass,
  toMixin,
  relinkSubclasses,
  wrapMethod
} from './mixin';

const { isArray } = Array;

import { ROOT } from './utils';

export const EMPTY_CACHE = function EMPTY_CACHE() {};

const CLASS_META = "df8be4c8-4e89-44e2-a8f9-550c8dacdca7";

export interface ObjectWithMixins {
  "df8be4c8-4e89-44e2-a8f9-550c8dacdca7": ClassMeta;
  _meta: Meta;
}

export interface InstanceWithMixins {
  constructor: ObjectWithMixins;
}

export interface GlimmerObjectFactory<T> {
  new<U>(attrs?: U): T & U;
  extend(): GlimmerObjectFactory<Object>;
  extend<T>(extension: T): GlimmerObjectFactory<T>;
  extend(...extensions: Object[]): GlimmerObjectFactory<Object>;
  create<U>(attrs?: U): GlimmerObject & T & U;
  reopen<U>(extensions: U);
  reopenClass<U>(extensions: U);
  metaForProperty(property: string): Object;
  eachComputedProperty(callback: (string, Object) => void);
  "df8be4c8-4e89-44e2-a8f9-550c8dacdca7": InstanceMeta;
}

export function turbocharge(obj) {
  // function Dummy() {}
  // Dummy.prototype = obj;
  return obj;
}

abstract class SealedMeta extends Meta {
  addReferenceTypeFor(...args): InnerReferenceFactory<any> {
    throw new Error("Cannot modify reference types on a sealed meta");
  }
}

export class ClassMeta {
  private referenceTypes = dict<InnerReferenceFactory<any>>();
  private propertyMetadata = dict<any>();
  private concatenatedProperties = dict<any[]>();
  private hasConcatenatedProperties = false;
  private mergedProperties = dict<Object>();
  private hasMergedProperties = false;
  private mixins: Mixin[] = [];
  private appliedMixins: Mixin[] = [];
  private staticMixins: Mixin[] = [];
  private subclasses: GlimmerObjectFactory<any>[] = [];
  private slots: string[] = [];
  public InstanceMetaConstructor: typeof Meta = null;

  static fromParent(parent: ClassMeta) {
    let meta = new this();
    meta.reset(parent);
    return meta;
  }

  static for(object: ObjectWithMixins | InstanceWithMixins): ClassMeta {
    if (CLASS_META in object) return (<ObjectWithMixins>object)[CLASS_META];
    else if (object.constructor) return (<InstanceWithMixins>object).constructor[CLASS_META] || null;
    else return null;
  }

  init(object: GlimmerObject, attrs: Object) {
    if (typeof attrs !== 'object' || attrs === null) return;

    if (this.hasConcatenatedProperties) {
      let concatProps = this.concatenatedProperties;
      for (let prop in concatProps) {
        if (prop in attrs) {
          let concat = concatProps[prop].slice();
          object[prop] = concat.concat(attrs[prop]);
        }
      }
    }

    if (this.hasMergedProperties) {
      let mergedProps = this.mergedProperties;
      for (let prop in mergedProps) {
        if (prop in attrs) {
          let merged = assign({}, mergedProps[prop]);
          object[prop] = assign(merged, attrs[prop]);
        }
      }
    }
  }

  addStaticMixin(mixin: Mixin) {
    this.staticMixins.push(mixin);
  }

  addMixin(mixin: Mixin) {
    this.mixins.push(mixin);
  }

  getStaticMixins(): Mixin[] {
    return this.staticMixins;
  }

  getMixins(): Mixin[] {
    return this.mixins;
  }

  addAppliedMixin(mixin: Mixin) {
    this.appliedMixins.push(mixin);
  }

  hasAppliedMixin(mixin: Mixin): boolean {
    return this.appliedMixins.indexOf(mixin) !== -1;
  }

  getAppliedMixins(): Mixin[] {
    return this.appliedMixins;
  }

  hasStaticMixin(mixin: Mixin): boolean {
    return this.staticMixins.indexOf(mixin) !== -1;
  }

  static applyAllMixins(Subclass: GlimmerObjectFactory<any>, Parent: GlimmerObjectFactory<any>) {
    Subclass[CLASS_META].getMixins().forEach(m => m.extendPrototypeOnto(Subclass, Parent));
    Subclass[CLASS_META].getStaticMixins().forEach(m => m.extendStatic(Subclass));
    Subclass[CLASS_META].seal();
  }

  addSubclass(constructor: GlimmerObjectFactory<any>) {
    this.subclasses.push(constructor);
  }

  getSubclasses(): Function[] {
    return this.subclasses;
  }

  addPropertyMetadata(property: string, value: any) {
    this.propertyMetadata[property] = value;
  }

  metadataForProperty(property: string): Object {
    return this.propertyMetadata[property];
  }

  addReferenceTypeFor(property: string, type: InnerReferenceFactory<any>) {
    this.referenceTypes[property] = type;
  }

  addSlotFor(property: string) {
    this.slots.push(property);
  }

  hasConcatenatedProperty(property: string): boolean {
    if (!this.hasConcatenatedProperties) return false;
    return <string>property in this.concatenatedProperties;
  }

  getConcatenatedProperty(property: string): any[] {
    return this.concatenatedProperties[property];
  }

  getConcatenatedProperties(): string[] {
    return <string[]>Object.keys(this.concatenatedProperties);
  }

  addConcatenatedProperty(property: string, value: any) {
    this.hasConcatenatedProperties = true;

    if (<string>property in this.concatenatedProperties) {
      let val = this.concatenatedProperties[property].concat(value);
      this.concatenatedProperties[property] = val;
    } else {
      this.concatenatedProperties[property] = value;
    }
  }

  hasMergedProperty(property: string): boolean {
    if (!this.hasMergedProperties) return false;
    return <string>property in this.mergedProperties;
  }

  getMergedProperty(property: string): Object {
    return this.mergedProperties[property];
  }

  getMergedProperties(): string[] {
    return <string[]>Object.keys(this.mergedProperties);
  }

  addMergedProperty(property: string, value: Object) {
    this.hasMergedProperties = true;

    if (isArray(value)) {
      throw new Error(`You passed in \`${JSON.stringify(value)}\` as the value for \`foo\` but \`foo\` cannot be an Array`);
    }

    if (<string>property in this.mergedProperties && this.mergedProperties[property] && value) {
      this.mergedProperties[property] = mergeMergedProperties(value, this.mergedProperties[property]);
    } else {
      value = value === null ? value : value || {};
      this.mergedProperties[property] = value;
    }
  }

  getReferenceTypes(): Dict<InnerReferenceFactory<any>> {
    return this.referenceTypes;
  }

  getPropertyMetadata(): Dict<any> {
    return this.propertyMetadata;
  }

  reset(parent: ClassMeta) {
    this.referenceTypes = dict<InnerReferenceFactory<any>>();
    this.propertyMetadata = dict();
    this.concatenatedProperties = dict<any[]>();
    this.mergedProperties = dict<Object>();

    if (parent) {
      this.hasConcatenatedProperties = parent.hasConcatenatedProperties;
      for (let prop in parent.concatenatedProperties) {
        this.concatenatedProperties[prop] = parent.concatenatedProperties[prop].slice();
      }

      this.hasMergedProperties = parent.hasMergedProperties;
      for (let prop in parent.mergedProperties) {
        this.mergedProperties[prop] = assign({}, parent.mergedProperties[prop]);
      }

      assign(this.referenceTypes, parent.referenceTypes);
      assign(this.propertyMetadata, parent.propertyMetadata);
    }
  }

  reseal(obj: Object) {
    let meta = Meta.for(obj);
    let fresh = new this.InstanceMetaConstructor(obj, {});
    let referenceTypes = meta.getReferenceTypes();
    let slots = meta.getSlots();

    turbocharge(assign(referenceTypes, this.referenceTypes));
    turbocharge(assign(slots, fresh.getSlots()));
  }

  seal() {
    let referenceTypes: Dict<InnerReferenceFactory<any>> = turbocharge(assign({}, this.referenceTypes));
    turbocharge(this.concatenatedProperties);
    turbocharge(this.mergedProperties);

    if (!this.hasMergedProperties && !this.hasConcatenatedProperties) {
      this.init = function() {};
    }

    let slots = this.slots;

    class Slots {
      constructor() {
        slots.forEach(name => {
          this[name] = EMPTY_CACHE;
        });
      }
    }

    this.InstanceMetaConstructor = class extends SealedMeta {
      protected slots: Slots = new Slots();
      public referenceTypes: Dict<InnerReferenceFactory<any>> = referenceTypes;

      getReferenceTypes() {
        return this.referenceTypes;
      }

      referenceTypeFor(property: string): InnerReferenceFactory<any> {
        return this.referenceTypes[property] || PropertyReference;
      }

      getSlots() {
        return this.slots;
      }
    };

    turbocharge(this);
  }
}

function mergeMergedProperties(attrs: Object, parent: Object) {
  let merged = assign({}, parent);

  for (let prop in attrs) {
    if (prop in parent && typeof parent[prop] === 'function' && typeof attrs[prop] === 'function') {
      let wrapped = wrapMethod(parent, prop, attrs[prop]);
      merged[prop] = wrapped;
    } else {
      merged[prop] = attrs[prop];
    }
  }

  return merged;
}

export class InstanceMeta extends ClassMeta {
  public "df8be4c8-4e89-44e2-a8f9-550c8dacdca7": ClassMeta = ClassMeta.fromParent(null);

  static fromParent(parent: InstanceMeta): InstanceMeta {
    return <InstanceMeta>super.fromParent(parent);
  }

  reset(parent: InstanceMeta) {
    super.reset(parent);
    if (parent) this[CLASS_META].reset(parent[CLASS_META]);
  }

  seal() {
    super.seal();
    this[CLASS_META].seal();
  }
}

export default class GlimmerObject {
  static "df8be4c8-4e89-44e2-a8f9-550c8dacdca7": InstanceMeta = InstanceMeta.fromParent(null);
  static isClass = true;

  static extend(): typeof GlimmerObject;
  static extend<T>(extension: T): typeof GlimmerObject;
  static extend(...extensions: Object[]): typeof GlimmerObject;

  static extend(...extensions) {
    return extendClass(this, ...extensions);
  }

  static create(attrs?: Object): GlimmerObject {
    return new this(attrs);
  }

  static reopen<U>(extensions: U) {
    toMixin(extensions).extendPrototype(this);
    this[CLASS_META].seal();

    relinkSubclasses(this);
  }

  static reopenClass(extensions: Object) {
    toMixin(extensions).extendStatic(this);
    this[CLASS_META].seal();
  }

  static metaForProperty(property: string): Object {
    let value = this[CLASS_META].metadataForProperty(property);
    if (!value) throw new Error(`metaForProperty() could not find a computed property with key '${property}'.`);
    return value;
  }

  static eachComputedProperty(callback: (string, Object) => void) {
    let metadata = this[CLASS_META].getPropertyMetadata();
    if (!metadata) return;

    for (let prop in metadata) {
      callback(prop, metadata[prop]);
    }
  }

  _super = ROOT;
  _meta = null;
  _guid: number;

  init() {}

  constructor(attrs?: Object) {
    if (attrs) assign(this, attrs);
    (<typeof GlimmerObject>this.constructor)[CLASS_META].init(this, attrs);
    this._super = ROOT;
    initializeGuid(this);
    this.init();
  }

  get(key: string): any {
    return this[key];
  }

  set(key: string, value: any) {
    this[key] = value;
  }

  setProperties(attrs: Object) {
    assign(this, attrs);
  }

  destroy() {}
}
