/*
 * Licensed to Elasticsearch B.V. under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch B.V. licenses this file to you under
 * the Apache License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

/**
 * @name State
 *
 * @extends Events
 *
 * @description Persists generic "state" to and reads it from the URL.
 */

import _ from 'lodash';
import angular from 'angular';
import rison from 'rison-node';
import { applyDiff } from '../utils/diff_object';
import { EventsProvider } from '../events';
import { fatalError, toastNotifications } from '../notify';
import './config_provider';
import { createLegacyClass } from '../utils/legacy_class';
import { callEach } from '../utils/function';

import {
  createStateHash,
  HashedItemStoreSingleton,
  isStateHash,
} from './state_storage';

export function StateProvider(Private, $rootScope, $location, stateManagementConfig, config, kbnUrl) {
  const Events = Private(EventsProvider);

  createLegacyClass(State).inherits(Events);
  function State(
    urlParam,
    defaults,
    hashedItemStore = HashedItemStoreSingleton
  ) {
    State.Super.call(this);

    this.setDefaults(defaults);
    this._urlParam = urlParam || '_s';
    this._hashedItemStore = hashedItemStore;

    // When the URL updates we need to fetch the values from the URL
    this._cleanUpListeners = _.partial(callEach, [
      // partial route update, no app reload
      $rootScope.$on('$routeUpdate', () => {
        this.fetch();
      }),

      // beginning of full route update, new app will be initialized before
      // $routeChangeSuccess or $routeChangeError
      $rootScope.$on('$routeChangeStart', () => {
        if (!this._persistAcrossApps) {
          this.destroy();
        }
      }),

      $rootScope.$on('$routeChangeSuccess', () => {
        if (this._persistAcrossApps) {
          this.fetch();
        }
      })
    ]);

    // Initialize the State with fetch
    this.fetch();
  }

  State.prototype._readFromURL = function () {
    const search = $location.search();
    const urlVal = search[this._urlParam];

    if (!urlVal) {
      return null;
    }

    if (isStateHash(urlVal)) {
      return this._parseStateHash(urlVal);
    }

    let risonEncoded;
    let unableToParse;
    try {
      risonEncoded = rison.decode(urlVal);
    } catch (e) {
      unableToParse = true;
    }

    if (unableToParse) {
      toastNotifications.addDanger('Unable to parse URL');
      search[this._urlParam] = this.toQueryParam(this._defaults);
      $location.search(search).replace();
    }

    if (!risonEncoded) {
      return null;
    }

    if (this.isHashingEnabled()) {
      // RISON can find its way into the URL any number of ways, including the navbar links or
      // shared urls with the entire state embedded. These values need to be translated into
      // hashes and replaced in the browser history when state-hashing is enabled
      search[this._urlParam] = this.toQueryParam(risonEncoded);
      $location.search(search).replace();
    }

    return risonEncoded;
  };

  /**
   * Fetches the state from the url
   * @returns {void}
   */
  State.prototype.fetch = function () {
    if (!stateManagementConfig.enabled) {
      return;
    }

    let stash = this._readFromURL();

    // nothing to read from the url? save if ordered to persist
    if (stash === null) {
      if (this._persistAcrossApps) {
        return this.save();
      } else {
        stash = {};
      }
    }

    _.defaults(stash, this._defaults);
    // apply diff to state from stash, will change state in place via side effect
    const diffResults = applyDiff(this, stash);

    if (diffResults.keys.length) {
      this.emit('fetch_with_changes', diffResults.keys);
    }
  };

  /**
   * Saves the state to the url
   * @returns {void}
   */
  State.prototype.save = function (replace) {
    if (!stateManagementConfig.enabled) {
      return;
    }

    let stash = this._readFromURL();
    const state = this.toObject();
    replace = replace || false;

    if (!stash) {
      replace = true;
      stash = {};
    }

    // apply diff to state from stash, will change state in place via side effect
    const diffResults = applyDiff(stash, _.defaults({}, state, this._defaults));

    if (diffResults.keys.length) {
      this.emit('save_with_changes', diffResults.keys);
    }

    // persist the state in the URL
    const search = $location.search();
    search[this._urlParam] = this.toQueryParam(state);
    if (replace) {
      $location.search(search).replace();
    } else {
      $location.search(search);
    }
  };

  /**
   * Calls save with a forced replace
   * @returns {void}
   */
  State.prototype.replace = function () {
    if (!stateManagementConfig.enabled) {
      return;
    }

    this.save(true);
  };

  /**
   * Resets the state to the defaults
   *
   * @returns {void}
   */
  State.prototype.reset = function () {
    if (!stateManagementConfig.enabled) {
      return;
    }

    kbnUrl.removeParam(this.getQueryParamName());
    // apply diff to attributes from defaults, this is side effecting so
    // it will change the state in place.
    const diffResults = applyDiff(this, this._defaults);
    if (diffResults.keys.length) {
      this.emit('reset_with_changes', diffResults.keys);
    }
    this.save();
  };

  /**
   * Cleans up the state object
   * @returns {void}
   */
  State.prototype.destroy = function () {
    this.off(); // removes all listeners
    this._cleanUpListeners(); // Removes the $routeUpdate listener
  };

  State.prototype.setDefaults = function (defaults) {
    this._defaults = defaults || {};
  };

  /**
   *  Parse the state hash to it's unserialized value. Hashes are restored
   *  to their pre-hashed state.
   *
   *  @param  {string} stateHash - state hash value from the query string.
   *  @return {any} - the stored value, or null if hash does not resolve.
   */
  State.prototype._parseStateHash = function (stateHash) {
    const json = this._hashedItemStore.getItem(stateHash);
    if (json === null) {
      toastNotifications.addDanger('Unable to completely restore the URL, be sure to use the share functionality.');
    }

    return JSON.parse(json);
  };

  /**
   *  Lookup the value for a hash and return it's value in rison format or just
   *  return passed argument if it's not recognized as state hash.
   *
   *  @param  {string} stateHashOrRison - either state hash value or rison string.
   *  @return {string} rison
   */
  State.prototype.translateHashToRison = function (stateHashOrRison) {
    if (isStateHash(stateHashOrRison)) {
      return rison.encode(this._parseStateHash(stateHashOrRison));
    }

    return stateHashOrRison;
  };

  State.prototype.isHashingEnabled = function () {
    return !!config.get('state:storeInSessionStorage');
  };

  /**
   *  Produce the hash version of the state in it's current position
   *
   *  @return {string}
   */
  State.prototype.toQueryParam = function (state = this.toObject()) {
    if (!this.isHashingEnabled()) {
      return rison.encode(state);
    }

    // We need to strip out Angular-specific properties.
    const json = angular.toJson(state);
    const hash = createStateHash(json, hash => {
      return this._hashedItemStore.getItem(hash);
    });
    const isItemSet = this._hashedItemStore.setItem(hash, json);

    if (isItemSet) {
      return hash;
    }

    // If we ran out of space trying to persist the state, notify the user.
    fatalError(
      new Error(
        'Kibana is unable to store history items in your session ' +
        'because it is full and there don\'t seem to be items any items safe ' +
        'to delete.\n' +
        '\n' +
        'This can usually be fixed by moving to a fresh tab, but could ' +
        'be caused by a larger issue. If you are seeing this message regularly, ' +
        'please file an issue at https://github.com/elastic/kibana/issues.'
      )
    );
  };

  /**
   *  Get the query string parameter name where this state writes and reads
   *  @return {string}
   */
  State.prototype.getQueryParamName = function () {
    return this._urlParam;
  };

  return State;

}
