import { assignIn, isArray, isFunction, isObject, isUndefined, uniqueId, assign, forEach, isPlainObject } from 'lodash';

require('es6-promise').polyfill();
require('isomorphic-fetch');


export interface Links<T> {
  /**
   * References to the current resultset
   */
  self?(): Promise<ApiResult<T>>;
  /**
   * References to the first portion of results
   */
  first?(): Promise<ApiResult<T>>;
  /**
   * References to the next portion of results
   */
  next?(): Promise<ApiResult<T>>;
  /**
   * References to the last portion of results
   */
  last?(): Promise<ApiResult<T>>;
}

export interface Meta {
  /**
   * the amount of datasets returned in this page of the response
   */
  count: number;
  /**
   * The total amount of datasets available without pagination
   */
  total: number;
  /**
   * The active limit of datasets per page
   */
  limit: number;
  /**
   * The current offset of total available datasets
   */
  offset: number;
  /**
   * The current page
   */
  page: number;
}

// tslint:disable-next-line: no-any
export type GenericApiResult = any;
export type VoidApiResult = void;

export interface ApiResult<T> {
  /**
   * Inidicates if the request itself was successfully sent to the API (no indication if the API operation was successful!)
   */
  success: boolean;
  /**
   * The result of the API operation
   */
  result: T;
  /**
   * The raw HTTP Response
   */
  response?: Response;
  /**
   * The Request options
   */
  requestInit?: RequestInit;
  /**
   * Links to other resultsets of the pagination
   */
  links?: Links<T>;
  /**
   * Pagination meta data
   */
  meta?: Meta;
  /**
   * If this is a function, it can be used to watch the process of asynchronous requests. The returned Promise is resolved when the request finished
   */
  watch?: () => Promise<ApiResult<RequestPollResult>> | null;
  /**
   * A unique request id (generated by this client)
   */
  id?: string | null;
  /**
   * indicates if the 'request' has failed or 'json' parsing failed
   */
  failureType?: string | null;
}


export interface ApiSettings {
  /**
   * The endpoint URL
   */
  endpoint?: string;
  /**
   * A map of specific URL overrides that should go to a different endpoint (e.g. a sandbox to test new API)
   * format "path:endpoint", path can be regex (string start and end with '/')
   * @example
   * { '/myNewObject\/(.*)/': 'https://myNewApi.getsandbox.com/myNewObject' }
   */
  endpointOverrides?: { [key: string]: string; }; // override endpoint for specific paths, format "path:endpoint", path can be regex (string start and end with '/')
  /**
   * The API token
   */
  token?: string;
  /**
   * The User UUID
   */
  userId?: string;
  /**
   * Default pagination limit
   */
  limit?: number;
  /**
   * Default Watchdelay in ms
   */
  watchdelay?: number;
  /**
   * Api Client identifier (used for X-Api-Client header)
   */
  apiClient?: string;
  /**
   * A custom fetch method
   */
  fetch?: Function;
}

export interface RequestOptions {
  /**
   * Page to get the resultset for
   */
  page?: number;
  /**
   * Maximum number of datasets to return per page
   */
  limit?: number;
  /**
   * Array of fields to return in the response (to reduce the size of the resultset)
   */
  fields?: string[];
  /**
   * Filters the results by a field
   * @example
   * "name=foo"
   */
  filter?: string[];
  /**
   * Field to sort the result after
   */
  sort?: string | string[];
}

export interface RequestPollResult {
  /**
   * Current status of the watched request
   */
  message: string;
  /**
   * Short status
   */
  status: string;
  /**
   * Time when the request was created
   */
  create_time: string;
}


export interface BaseRelationObject {
  object_name?: string;
  object_uuid?: string;
}

/**
 * interface with basic properties each object (server, storage ...) should have
 */
export interface BaseObject {
  object_uuid?: string;
  status?: string;
  name?: string;
  labels?: string[];
  location_uuid?: string;
  relations?: { [key: string]: BaseRelationObject[] };
}

export interface LogData {
  result: ApiResult<unknown>;
  response: Response;
  /**
   * Unique request id (generated by client)
   */
  id: string;
  requestInit: RequestInit;
}

export class GSError extends Error {
  result: GenericApiResult;
  success = false;
  response: Response;

  constructor(message, result) {
    super();
    this.name = 'GridscaleError';

    // try to assemble message with more details from result
    if ( result.response
        && result.response.request
        && result.response.request.method
        && typeof (result.response.status) !== 'undefined' && result.response.request.url) {
      this.message = 'Error : ' + result.response.request.method
                    + ' | ' + result.response.status
                    + ' | ' + result.response.request.url.split('?')[0];

    } else {
      this.message = message || 'Default Message';
    }

    this.result = result;
    this.response = result.response || undefined;
  }
}



export class APIClass {
    // Local Settings
    private settings: ApiSettings = {
      endpoint: 'https://api.gridscale.io',
      endpointOverrides: {},
      token: '',
      userId: '',
      limit: 25,
      watchdelay: 1000,
      apiClient: 'gsclient-js'
    };

    /**
     * Store api client in current session
     * @param _client  String
     */
    public storeClient(_client: string) {
      this.settings.apiClient = _client;
    }

    /**
     * Store Token for Current Session
     * @param _token Secret Token
     */
    public storeToken(_token: string, _userId: string) {
      // Store Token
      this.settings.token = _token;
      this.settings.userId = _userId;
    }

    /**
     * Update local Request Options
     *
     * @param _option
     */
    public setOptions = (_option: ApiSettings) => {
      // Assign new Values
      assignIn(this.settings, _option);
    }

    /**
     * Start the API Request
     *
     * @param _path
     * @param _options
     * @param _callback
     * @returns {Promise}
     */
    public request(_path: string = '', _options: RequestInit, _callback: (response: Response, result: ApiResult<GenericApiResult>) => void = (response, result) => { }): Promise<ApiResult<GenericApiResult>> {
      const options: RequestInit = !isObject(_options) ? {} : assignIn( {}, _options );

      // check if we should use another endpoint for this path (mocking)
      var endpoint = this.settings.endpoint;
      if (this.settings.endpointOverrides && typeof(this.settings.endpointOverrides) === 'object') {
        forEach(this.settings.endpointOverrides, (_overrideEndpoint, _overridePath) => {
          if (_overridePath.match(/^\/(.*)\/$/) && _path.split('?')[0].match(new RegExp(RegExp.$1))) {
            endpoint = _overrideEndpoint;

          } else if (_path.split('?')[0] === _overridePath) {
            endpoint = _overrideEndpoint;

          } else {
            return true;
          }

          return false;
        });
      }



      // Build Options
      const url: string = _path.search('https://') === 0 ? _path :  endpoint + _path; // on Links there is already
      options.headers = options.headers ? options.headers : {};
      options.headers['X-Auth-UserId'] = this.settings.userId;
      options.headers['X-Auth-Token'] = this.settings.token;
      options.headers['X-Api-Client'] = this.settings.apiClient;

      // return results as object or text
      const getResult = (_response: Response, _rejectOnJsonFailure = true): Promise<GenericApiResult> => {
        return new Promise((_resolve, _reject) => {
          if (_response.status !== 204 && _response.headers.has('Content-Type') && _response.headers.get('Content-Type').indexOf('application/json') === 0) {
            _response.json()
              .then(json => {
                _resolve(json);
              })
              .catch(() => {
                if (_rejectOnJsonFailure) {
                  _reject();

                } else {
                  // try text
                  _response.text().then(text => _resolve(text))
                                  .catch(e => _resolve(null));
                }
              }
            );
          } else {
            _response.text().then(text => _resolve(text))
                            .catch(e => _resolve(null));
          }
        });
      };

      // Setup DEF
      const def: Promise<ApiResult<GenericApiResult>> = new Promise( ( _resolve, _reject ) => {
        // Fire Request
        const onSuccess = (_response: Response, _request: Request, _requestInit: RequestInit) => {
          getResult(_response.clone()).then((_result) => {
            const result: ApiResult<GenericApiResult> = {
              success: true,
              result: _result,
              response: _response.clone(),
              id: null,
              requestInit: _requestInit
            };

            // Check for Links and generate them as Functions
            if (_result && _result._links) {
              const links = {};
              forEach(_result._links, (link, linkname) => {
                links[linkname] = this.link(_result._links[linkname]);
              });
              result.links = links;
            }

            if (_result && _result._meta) {
              result.meta = _result._meta;
            }

            /**
             * On POST, PATCH and DELETE Request we will inject a watch Function into the Response so you can easiely start watching the current Job
             */
            if (options['method'] === 'POST' || options['method'] === 'PATCH' || options['method'] === 'DELETE') {
              if (result.response.headers.has('x-request-id')) {
                result.watch = () => this.watchRequest(result.response.headers.get('x-request-id'));
              }
            }

            _resolve(result);
            setTimeout(() => _callback(_response.clone(), result));
          })
          .catch(() => {
            // tslint:disable-next-line: no-use-before-declare
            onFail(_response, _request, _requestInit, 'json');
          });
        };
        const onFail = (_response: Response, _request: Request, _requestInit: RequestInit, _failType = 'request') => {
          getResult(_response.clone(), false).then((_result) => {
            const result: ApiResult<GenericApiResult> = {
              success: false,
              result: _result,
              response: assign(_response.clone(), { request: _request }),
              links: {},
              watch: null,
              id: uniqueId('apierror_' + (new Date()).getTime() + '_'),
              requestInit: _requestInit,
              failureType: _failType
            };

            this.log({
              result: result,
              response: _response.clone(),
              id: result.id,
              requestInit: result.requestInit
            });

            _reject( new GSError('Request Error', result) );
            setTimeout(() => _callback(_response.clone(), result));
          });
        };


        const request = new Request(url, options);
        const promise = (this.settings.fetch || fetch)(request);
        promise
          .then((_response) => {
            if (_response.ok) {
              // The promise does not reject on HTTP errors
              onSuccess(_response, request, options);

            } else {
              onFail(_response, request, options);
            }
          })
          .catch((_response) => {
            _reject(new GSError('Network failure', _response));
          });

        // Return promise
        return promise;
      } );


      // Catch all Errors and


      // Return DEF
      return def;
    }




    /**
     * Build Option URL to expand URL
     * @param _options
     * @returns {string}
     */
    private buildRequestURL(_options) {
      // Push Valued
      const url = [];

      // Add Options to URL
      forEach(_options, (val, key) => {
          if ( isArray(_options[key]) ) {
            if (_options[key].length > 0) {
              url.push(key + '=' + _options[key].join(',') );
            }
          } else {
              url.push(key + '=' + _options[key] );
          }
      });

      return url.length > 0 ? ('?' + url.join('&')) : '';
    }


    /**
     * Start Get Call
     * @param _path
     * @param _callback
     */
    public get(_path: string, _options?: RequestOptions | Function, _callback?: (response: Response, result: ApiResult<GenericApiResult>) => void): Promise<ApiResult<GenericApiResult>> {
      if ( isObject( _options ) ) {
          _path += this.buildRequestURL( _options );
      }

      // If No Options but Callback is given
      if ( isUndefined( _callback ) && isFunction( _options ) ) {
          _callback = _options;
      }

      return this.request(_path, {method: 'GET'}, _callback );
    }

    /**
     * Start Delete Call
     * @param _path
     * @param _callback
     */
    public remove(_path: string, _callback?: (response: Response, result: ApiResult<GenericApiResult>) => void): Promise<ApiResult<GenericApiResult>> {
      return this.request(_path, {method: 'DELETE'}, _callback );
    }


    /**
     * Send Post Request
     *
     * @param _path Endpoint
     * @param _attributes  Attributes for Post Body
     * @param _callback Optional Callback
     * @returns {Promise}
     */
    public post(_path: string, _attributes: Object, _callback?: (response: Response, result: ApiResult<GenericApiResult>) => void): Promise<ApiResult<GenericApiResult>> {
      return this.request(_path, { method : 'POST', body  : JSON.stringify(_attributes), headers: {'Content-Type': 'application/json' } }, _callback );
    }

    /**
     * Send PAtCH Request
     *
     * @param _path Endpoint
     * @param _attributes  Attributes for Post Body
     * @param _callback Optional Callback
     * @returns {Promise}
     */
    public patch(_path: string, _attributes: Object, _callback?: (response: Response, result: ApiResult<GenericApiResult>) => void): Promise<ApiResult<GenericApiResult>> {
      return this.request(_path, { method : 'PATCH', body  : JSON.stringify(_attributes), headers: {'Content-Type': 'application/json' } }, _callback );
    }


    /**
     * Generate URL for Linked Request. No Options are required because its in the URL already
     *
     * @param _link
     * @param _callback
     * @returns {Function}
     */
    private link( _link ): (_callback: (response: Response, result: ApiResult<GenericApiResult>) => void) => Promise<ApiResult<GenericApiResult>> {
      /**
       * generate Function that has an Optional Callback
       */
      return (_callback?: (response: Response, result: ApiResult<GenericApiResult>) => void): Promise<ApiResult<GenericApiResult>> => {
        return this.request(_link.href, {method: 'GET'}, _callback );
      };
    }


    /**
     * Start Pooling on Request Endpoint
     *
     *
     * @param _requestid
     * @param _callback
     * @returns {Promise}
     */
    public requestpooling(_requestid: string, _callback?: (response: Response, result: ApiResult<GenericApiResult>) => void): Promise<ApiResult<{ [uuid: string]: RequestPollResult }>> {
      return this.request('/requests/' + _requestid, {method: 'GET'}, _callback );
    }


    /**
     * Recursive creating of Request Proises
     *
     *
     * @param _requestid
     * @param _resolve
     * @param _reject
     */
    public buildAndStartRequestCallback( _requestid: string , _resolve: Function, _reject: Function): void {
      /**
       * Start new Request
       */
      this.requestpooling(_requestid).then((_result) => {
        // Check Request Status to Decide if we start again


        if (_result.result[ _requestid ].status === 'pending') {

          setTimeout(() => {
              this.buildAndStartRequestCallback(_requestid , _resolve, _reject);
          }, this.settings.watchdelay );

        } else if ( _result.response.status === 200 ) {

          // Job done
          _resolve(_result);
        } else {

          // IF
          _reject(_result);
        }

      }, (_result) => _reject(_result) );
    }


    /**
     * Watch a Single Request until it is ready or failed
     *
     * @param _requestid
     * @param _callback
     */
    public watchRequest(_requestid: string): Promise<ApiResult<RequestPollResult>> {
      return new Promise( ( _resolve, _reject ) => {
        this.buildAndStartRequestCallback(_requestid , _resolve, _reject);
      });
    }


    private callbacks = [];
    /**
     * Adds a new logger for error logging
     * @param _callback
     */
    public addLogger = (_callback: (logData: LogData) => void) => {
      this.callbacks.push(_callback);
    }

    private log = (_logData: LogData) => {
      for (var i = 0; i < this.callbacks.length; i++) {
        this.callbacks[i](_logData);
      }
    }
}

export const api = new APIClass();
