/* global global:writable */

import * as semver from 'semver';
import sodium from 'sodium-javascript';

import { HttpClient } from '../../services/http-client/http-client';

export const LOCATION = {
  LOCAL: 'local',
  SESSION: 'session',
  WINDOW: 'window',
  HISTORY: 'history',
};

const LOCATION_SHADOWING = [ LOCATION.HISTORY, LOCATION.WINDOW, LOCATION.SESSION, LOCATION.LOCAL ];

export class StorageManager {

  namespace;
  version;

  _options;
  _global;

  _encryptionKey = {};

  constructor(namespace, version, options) {
    this._debugLog('constructor', namespace, version, options);

    if (typeof namespace === 'object') {
      let sep = '_';
      const namespace_arr = Array.from(namespace);
      while (namespace_arr.some(str => str.includes(sep)))
        sep = sep + '_';
      
      this.namespace = Array.from(namespace).join(sep);
    } else {
      this.namespace = namespace;
    }
    this.version = semver.valid(version);

    if (!namespace) {
      throw new Error('Cannot setup StorageManager without namespace');
    }
    if (!this.version) {
      throw new Error('Cannot setup StorageManager without valid semver version');
    }

    if (options !== undefined) {
      this._options = options;
    }

    if (global !== undefined)
      this._global = global;
    else if (window !== undefined)
      this._global = window;
    else
      throw new Error('Cannot setup StorageManager without window or global');
    
    if (this._options.encryption) {
      [LOCATION.LOCAL, LOCATION.SESSION, LOCATION.HISTORY].forEach((location) => {
        const encryption = this._options.encryption.all || this._options.encryption[location];
        if (encryption) this._requestStorageKey({ location: location, encryption: encryption });
      });
    }
  }

  save(key, value, options=this._options) {
    this._debugLog('save', key, value, options);

    if (!key) {
      throw new Error('StorageManager.save called with invalid key');
    }

    if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
      throw new Error('StorageManager.save called with unsupported location option');
    }

    const location = options && options.location !== undefined ? options.location : LOCATION_SHADOWING[0];

    switch (location) {
      case LOCATION.LOCAL: {
        this._saveToLocalStorage({ ...this._getFromLocalStorage(options), [key]: value}, options);
        break;
      }
      case LOCATION.SESSION: {
        this._saveToSessionStorage({ ...this._getFromSessionStorage(options), [key]: value}, options);
        break;
      }
      case LOCATION.WINDOW: {
        this._saveToWindow({ ...this._getFromWindow(), [key]: value });
        break;
      }
      case LOCATION.HISTORY: {
        this._saveToHistory({ ...this._getFromHistory(), [key]: value }, options);
        break;
      }
      default:
        console.error('StorageManager.save cannot save item with unsupported location');
    }
  }

  load(key, options=this._options) {
    this._debugLog('load', key, options);

    if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
      throw new Error('StorageManager.load called with unsupported location option');
    }

    let locations = options && options.location !== undefined ? [options.location] : LOCATION_SHADOWING;

    while (locations.length > 0) {
      const location = locations.shift();
      let val;
      
      switch (location) {
        case LOCATION.LOCAL: {
          val = this._getFromLocalStorage(options)[key];
          break;
        }
        case LOCATION.SESSION: {
          val = this._getFromSessionStorage(options)[key];
          break;
        }
        case LOCATION.WINDOW: {
          val = this._getFromWindow()[key];
          break;
        }
        case LOCATION.HISTORY: {
          val = this._getFromHistory(options)[key];
          break;
        }
        default:
          console.error('StorageManager.load cannot load item with unsupported location');
      }

      if (val !== undefined || locations.length === 0) {
        return val;
      }
    }
  }

  remove(key, options=this._options) {
    this._debugLog('remove', key, options);

    if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
      throw new Error('StorageManager.load called with unsupported location option');
    }

    const locations = options && options.location !== undefined ? [options.location] : LOCATION_SHADOWING;

    for (const location of locations) {
      switch (location) {
        case LOCATION.LOCAL: {
          let val = this._getFromLocalStorage(options);

          delete val[key];

          return this._saveToLocalStorage(val, options);
        }
        case LOCATION.SESSION: {
          let val = this._getFromSessionStorage(options);

          delete val[key];

          return this._saveToSessionStorage(val, options);
        }
        case LOCATION.WINDOW: {
          let val = this._getFromWindow();

          delete val[key];

          return this._saveToWindow(val);
        }
        case LOCATION.HISTORY: {
          let val = this._getFromHistory(options);

          delete val[key];

          return this._saveToHistory(val, options);
        }

        default:
          console.error('StorageManager.load cannot load item with unsupported location');
      }
    }
  }

  clear(options) {
    this._debugLog('clear', options);

     if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
      throw new Error('StorageManager.clear called with unsupported location option');
    }

    const locations = options && options.location !== undefined ? [options.location] : LOCATION_SHADOWING;

    for (const location of locations) {
      switch (location) {
        case LOCATION.LOCAL:
          return this._clearLocalStorage();
        case LOCATION.SESSION:
          return this._clearSessionStorage();
        case LOCATION.WINDOW:
          return this._clearWindow();
        case LOCATION.HISTORY:
          return this._clearHistory(options && options.history);
        default:
          console.error('StorageManager.clear cannot clear with unsupported location');
      }
    }
   
  }

  
  _getFromLocalStorage(options) {
    this._debugLog('_getFromLocalStorage', options);

    let state;

    try {
      state = JSON.parse(window.localStorage.getItem(this.namespace));
    } catch {
      state = null;
    }

    if (state === null || !state.version || !semver.satisfies(this.version, `^${state.version}`)) {
      // remove item from localStorage if it stores an invalid state
      this._clearLocalStorage();
      return {};
    }

    if ('state' in state)
      return this._getFromStorage(state.state, LOCATION.LOCAL, options);
    else {
      delete state.version;
      return this._getFromStorage(state, LOCATION.LOCAL, options);
    }
  }

  _saveToLocalStorage(state, options) {
    this._debugLog('_saveToLocalStorage', state);

    if (!state)
      return this._clearLocalStorage();

    let versionedState;
    
    if ('version' in state || 'state' in state) {
      versionedState = { version: this.version, state: state };
    } else {
      versionedState = { version: this.version, ...state };
    }

    window.localStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.LOCAL, options)));
  }

  _clearLocalStorage() {
    this._debugLog('_clearLocalStorage');

    window.localStorage.removeItem(this.namespace);
  }

  
  _getFromWindow() {
    this._debugLog('_getFromWindow');

    if (!this._global || !this._global.App)
      return {};

    if (!this._global.App.Storage || !this._global.App.Storage[this.namespace])
      return {};

    return this._global.App.Storage[this.namespace];
  }

  _saveToWindow(value) {
    this._debugLog('_saveToWindow', value);

    if (!this._global || !this._global.App) {
      return console.error('StorageManager._saveToWindow called when window.App is not available');
    }

    if (!value)
      return this._clearWindow();

    if (!this._global.App.Storage)
      this._global.App.Storage = {};
 
    this._global.App.Storage[this.namespace] = value;
  }

  _clearWindow() {
    this._debugLog('_clearWindow');

    if (!this._global || !this._global.App) {
      return console.error('StorageManager._saveToWindow called when window.App is not available');
    }
 
    if (this._global.App.Storage) {
      delete this._global.App.Storage[this.namespace];
    }
  }

  _getFromHistory(options) {
    this._debugLog('_getFromHistory');

    if (!this._global || !this._global.history)
      return {};

    if (!this._global.history.state || !this._global.history.state[this.namespace])
      return {};

    return this._getFromStorage(this._global.history.state[this.namespace], LOCATION.HISTORY, options);
  }

  _saveToHistory(value, options) {
    this._debugLog('_saveToHistory', options);


    if (!this._global || !this._global.history) {
      throw new Error('StorageManager._saveToHistory called when window.history is not available');
    }

    const push = (options.history && typeof options.history.push !== 'undefined') ? !!options.history.push : true;
    const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || '';
    const url = (options.history && options.history.url) || (this._global.document && this._global.document.location);
    
    const state = this._global.history.state || {};
    state[this.namespace] = this._updateStorage({}, value, LOCATION.HISTORY, options);

    this._debugLog('_saveToHistory', { state: state, push: push, title: title, url: url});

    if (push)
      this._global.history.pushState(state, title, url);
    else
      this._global.history.replaceState(state, title, url);
  }

  _clearHistory(options) {
    this._debugLog('_clearHistory', options);

    if (!this._global || !this._global.history) {
      throw new Error('StorageManager._clearHistory called when window.history is not available');
    }

    const push = (options.history && typeof options.history.push !== 'undefined' ? !!options.history.push : true) || true;
    const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || '';
    const url = (options.history && options.history.url) || (this._global.document && this._global.document.location);

    const state = this._global.history.state || {};
    delete state[this.namespace];

    if (push)
      this._global.history.pushState(state, title, url);
    else
      this._global.history.replaceState(state, title, url);
  }

  addHistoryListener(listener, options=this._options, ...args) {
    const modified_listener = (function(event, ...listener_args) { // eslint-disable-line no-unused-vars
      this._global.setTimeout(() => listener(this._getFromHistory(options), ...listener_args));
    }).bind(this);

    this._global.addEventListener('popstate', modified_listener, args);
  }


  _getFromSessionStorage(options) {
    this._debugLog('_getFromSessionStorage', options);

    let state;

    try {
      state = JSON.parse(window.sessionStorage.getItem(this.namespace));
    } catch {
      state = null;
    }

    if (state === null || !state.version || !semver.satisfies(this.version, `^${state.version}`)) {
      // remove item from session if it stores an invalid state
      this._clearSessionStorage();
      return {};
    }

    if ('state' in state)
      return this._getFromStorage(state.state, LOCATION.SESSION, options);
    else {
      delete state.version;
      return this._getFromStorage(state, LOCATION.SESSION, options);
    }
  }

  _saveToSessionStorage(state, options) {
    this._debugLog('_saveToSessionStorage', state);

    if (!state)
      return this._clearSessionStorage();

    let versionedState;
    
    if ('version' in state || 'state' in state) {
      versionedState = { version: this.version, state: state };
    } else {
      versionedState = { version: this.version, ...state };
    }
    
    window.sessionStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.SESSION, options)));
  }

  _clearSessionStorage() {
    this._debugLog('_clearSessionStorage');

    window.sessionStorage.removeItem(this.namespace);
  }


  _getFromStorage(storage, location, options) {
    this._debugLog('_getFromStorage', storage, location, options);

    const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]);
    if (encryption && storage.encryption) {
      return { ...storage, ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null) };
    } else {
      return storage;
    }
  }

  _updateStorage(storage, update, location, options) {
    this._debugLog('_updateStorage', storage, update, location, options);

    const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]);
    if (encryption && storage.encryption) {
      const updatedDecryptedStorage = { ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null), ...update };
      console.log('updatedDecryptedStorage', updatedDecryptedStorage);
      return { ...storage, encryption: { ...storage.encryption, ciphertext: encrypt(JSON.stringify(updatedDecryptedStorage), this._encryptionKey[location]) } };
    } else {
      return { ...storage, ...update };
    }
  }

  _requestStorageKey(options=this._options) {
    this._debugLog('_requestStorageKey', options);

    if (!(options && options.location && options.encryption))
      throw new Error('Storage Manager cannot request storage key with unsupported options!');

    const enc = this.load('encryption', { ...options, encryption: false });
    const requestBody = {
      type      : options.encryption,
      length    : sodium.crypto_secretbox_KEYBYTES,
      salt      : enc.salt,
      timestamp : enc.timestamp,
    };

    this._global.App.httpClient.post({
      url: '/user/storage-key',
      headers: {
        'Content-Type' : HttpClient.ACCEPT.JSON,
        'Accept'       : HttpClient.ACCEPT.JSON,
      },
      body: JSON.stringify(requestBody),
    }).then(
      (response) => response.json()
    ).then((response) => {
      console.log('storage-manager got key from response:', response, 'with options:', options);
      if (response.salt !== requestBody.salt || response.timestamp !== requestBody.timestamp) {
        this.clear(options);
      }
      this.save('encryption', { salt: response.salt, timestamp: response.timestamp }, { ...options, encryption: false });
      this._encryptionKey[options.location] = response.key;
    }).catch(console.error);
  }

  _debugLog() {}
  // _debugLog(fName, ...args) {
  //   console.log(`[DEBUGLOG] StorageManager.${fName}`, { args: args, instance: this });
  // }
}


// TODO debug unnecessary calls of encrypt
function encrypt(plaintext, key) {
  console.log('encrypt', plaintext, key);

  if (!plaintext) return '';
  if (!key) throw new Error('Cannot encrypt plaintext without a valid key!');

  /* eslint-disable no-undef */
  // TODO use const if possible
  let plaintextB = Buffer.from(plaintext);
  let cipherB    = Buffer.alloc(plaintextB.length + sodium.crypto_secretbox_MACBYTES);
  let nonceB     = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
  let keyB       = Buffer.from(key);
  /* eslint-enable no-undef */

  sodium.crypto_secretbox_easy(cipherB, plaintextB, nonceB, keyB);

  const result = cipherB;
  console.log('encrypt result', result);
  return result;
}

// TODO debug unnecessary calls of decrypt
function decrypt(ciphertext, key) {
  console.log('decrypt', ciphertext, key);

  if (!ciphertext) return '';
  if (!key) throw new Error('Cannot decrypt ciphertext without a valid key!');
  
  /* eslint-disable no-undef */
  // TODO use const if possible
  let cipherB    = Buffer.from(ciphertext);
  let plaintextB = Buffer.alloc(cipherB.length - sodium.crypto_secretbox_MACBYTES);
  let nonceB     = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
  let keyB       = Buffer.from(key);
  /* eslint-enable no-undef */

  sodium.crypto_secretbox_open_easy(plaintextB, cipherB, nonceB, keyB);

  const result = plaintextB.toString();
  console.log('decrypt result', result);
  return result;
}
