// tslint:disable max-classes-per-file

import * as jiff from 'jiff';
import * as moment from 'moment';

import { SubscribableOrPromise } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { Subscriber } from 'rxjs/Subscriber';
import { Subscription } from 'rxjs/Subscription';
import { ObjectUnsubscribedError } from 'rxjs/util/ObjectUnsubscribedError';

import { clone } from 'lodash';
import { isObject } from 'lodash';
import { isString } from 'lodash';
import { map } from 'lodash';

import { Deferred as IDeferred, JSONPatch, LocalForage } from './types';

let hiddenKeyRegex = /^\$+/;

let TIMESTAMP_RE = /^(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+)|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d)|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d)$/; // tslint:disable-line max-line-length

export function convertJsonDates(jsonData: any) {
  let outObj;
  if (Array.isArray(jsonData)) {
    outObj = [];
  } else {
    outObj = {};
  }
  for (let key of Object.keys(jsonData)) {
    let val = jsonData[key];
    let res = val;
    if (isString(val)) {
      // The value is a string - does it match any of our things we want to convert
      if (TIMESTAMP_RE.test(val)) {
        // We're probably a timestamp
        let dt = moment(val);
        if (dt.isValid()) {
          res = dt.toDate();
        }
      }
    } else if (Array.isArray(val) || isObject(val)) {
      res = convertJsonDates(val);
    }

    outObj[key] = res;
  }

  return outObj;
}

export function toJSON(obj: any) {
  return JSON.parse(JSON.stringify(obj));
}

export function diff(obj1: any, obj2: any) {
  obj1 = toJSON(obj1);
  obj2 = toJSON(obj2);

  return jiff.diff(obj1, obj2);
}

export function applyPatch(res: any, patch: JSONPatch) {
  // Go to JSON - don't do anything with dates
  let obj = JSON.parse(JSON.stringify(res, toJsonReplacer));
  obj = jiff.patch(patch, obj);

  // Go back to dates etc
  obj = convertJsonDates(obj);

  removeResValues(res);
  setResValues(res, obj);
}

export function mergeObjects(mine: any, old: any, yours: any) {

  // Make copies - we might modify the objects if ids dont exist
  mine = clone(mine);
  old = clone(old);

  // First sort out _id's. We could have a new id if one doesn't exist on mine and old
  if (!mine._id && !old._id && yours._id) {
    mine._id = yours._id;
    old._id = yours._id;
  }

  // We also need to convert into and out of dates
  mine = toJSON(mine);
  old = toJSON(old);
  yours = toJSON(yours);

  let yourpatch = jiff.diff(old, yours);
  let mypatch = jiff.diff(old, mine);

  let mypaths = map(mypatch, 'path');
  let patch = yourpatch.filter((patchval: any) => {
    return mypaths.indexOf(patchval.path) === -1;
  });

  let patched = jiff.patch(patch, mine);
  return convertJsonDates(patched);
}

export function forEachVal(res: any, cb: Function) {
  for (let key in res) {
    if (!hiddenKeyRegex.test(key)) {
      cb(res, key);
    }
  }
}

export function toJsonReplacer(key: any, value: any) {
  let val = value;
  if (isString(key) && key.charAt(0) === '$') {
    val = undefined;
  }

  return val;
}

export function fromJsonReviver(_: any, value: any) {
  let val = value;
  if (isString(value) && TIMESTAMP_RE.test(value)) {
    let dt = moment(value);
    if (dt.isValid()) {
      val = dt.toDate();
    }
  }

  return val;
}

export function toObject(res: any) {
  // The toJsonReplacer gets rid of attributes beginning with $ and the fromJsonReviver
  // converts date strings back into dates
  let str = JSON.stringify(res, toJsonReplacer);

  try {
    return JSON.parse(str, fromJsonReviver);
  } catch (err) {
    // Older versions of IE8 throw 'Out of stack space' errors if we use a reviver function
    // due to the bug described here: http://support.microsoft.com/kb/976662. Everyone hates
    // IE
    if (err.message === 'Out of stack space') {
      return convertJsonDates(JSON.parse(str));
    }

    throw(err);
  }
}

export function removeResValues(res: any) {
  forEachVal(res, (_: any, key: any) => {
    delete res[key];
  });
}

export function setResValues(res: any, vals: any) {
  for (let key of Object.keys(vals)) {
    res[key] = vals[key];
  }
}

export function persistentStorageKey(url: string) {
  return 'or2ws:' + url;
}

export function advancedStorage(localForage: LocalForage) {
  return localForage.driver() !== 'localStorageWrapper';
}

// lifted from here -> http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523
export function uuid() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    let r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); // tslint:disable-line
    return v.toString(16);
  });
}

export class Deferred<T> implements IDeferred<T> {
  public promise: Promise<T>;
  public resolve: (val?: T) => void;
  public reject: (err: Error) => void;

  constructor() {
    /* A method to resolve the associated Promise with the value passed.
     * If the promise is already settled it does nothing.
     *
     * @param {anything} value : This value is used to resolve the promise
     * If the value is a Promise then the associated promise assumes the state
     * of Promise passed as value.
     */
    this.resolve = null;

    /* A method to reject the assocaited Promise with the value passed.
     * If the promise is already settled it does nothing.
     *
     * @param {anything} reason: The reason for the rejection of the Promise.
     * Generally its an Error object. If however a Promise is passed, then the Promise
     * itself will be the reason for rejection no matter the state of the Promise.
     */
    this.reject = null;

    /* A newly created Pomise object.
     * Initially in pending state.
     */
    this.promise = new Promise((resolve, reject) => {
      this.resolve = resolve;
      this.reject = reject;
    });
    Object.freeze(this);
  }
}

export function isSubscribableOrPromise<T>(value: any): value is SubscribableOrPromise<T> {
  return value && (typeof value.subscribe === 'function' || typeof value.then === 'function');
}

// Like BehaviorSubject but waits for a promise to resolve
export class ResolvedSubject<T> extends Subject<T> {
  private _value: T;
  private _resolved: boolean;

  constructor(promise: Promise<T>) {
    super();

    promise.then(val => {
      this._resolved = true;
      this.next(val);
    });
  }

  public get value() {
    return this.getValue();
  }

  public getValue() {
    if (this.hasError) {
      throw this.thrownError;
    } else if (this.closed) {
      throw new ObjectUnsubscribedError();
    } else {
      return this._value;
    }
  }

  public next(value: T) {
    if (this._resolved) {
      this._value = value;
      super.next(value);
    }
  }

  protected _subscribe(subscriber: Subscriber<T>): Subscription {
    const subscription = super._subscribe(subscriber);
    if (this._resolved && subscription && !subscription.closed) {
        subscriber.next(this._value);
    }
    return subscription;
  }
}

export function convertToResolvedSubject<T>(subject: Subject<T>, promise: Promise<T>) {
  let resolvedSub = new ResolvedSubject<T>(promise);
  subject.subscribe(resolvedSub);
  return resolvedSub;
}
