// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.

// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with Parity.  If not, see <http://www.gnu.org/licenses/>.

import { action, computed, observable } from 'mobx';
import store from 'store';

import { sha3 } from '@parity/api/lib/util/sha3';

import { methodGroupFromMethod } from './methodGroups';

const LS_PERMISSIONS = '_parity::dapps::methods';

export default class Store {
  @observable requests = {}; // Maps requestId to request

  middleware = [];
  permissions = {}; // Maps `${method}:${appId}` to true/false
  sources = {}; // Maps requestId to a postMessage source
  tokens = {}; // Maps token to appId

  constructor (provider) {
    this.provider = provider;
    this.permissions = store.get(LS_PERMISSIONS) || {};

    window.addEventListener('message', this.receiveMessage, false);
  }

  @computed get hasRequests () {
    return Object.keys(this.requests).length !== 0;
  }

  @computed get groupedRequests () {
    // Group by appId on top level, and by methodGroup on 2nd level
    return Object.keys(this.requests).reduce((accumulator, requestId) => {
      const { data } = this.requests[requestId];
      const appId = this.tokens[data.token];
      const method = this.getMethodFromRequest(requestId);
      const methodGroup = methodGroupFromMethod[method]; // Get the methodGroup the current request belongs to

      accumulator[appId] = accumulator[appId] || {};
      accumulator[appId][methodGroup] = accumulator[appId][methodGroup] || [];
      accumulator[appId][methodGroup].push({ data, requestId }); // Push request & append the requestId field in the request object

      return accumulator;
    }, {});
  }

  @action queueRequest = (requestId, { data, source }) => {
    this.sources[requestId] = source;
    // Create a new this.requests object to update mobx store
    this.requests = {
      ...this.requests,
      [requestId]: { data }
    };
  };

  @action approveRequest = requestId => {
    const { data } = this.requests[requestId];
    const method = this.getMethodFromRequest(requestId);
    const appId = this.tokens[data.token];
    const source = this.sources[requestId];

    this.addAppPermission(method, appId);
    this.removeRequest(requestId);

    if (data.api) {
      this.executePubsubCall(data, source);
    } else {
      this.executeMethodCall(data, source);
    }
  };

  @action rejectRequest = requestId => {
    const { data } = this.requests[requestId];
    const source = this.sources[requestId];

    this.removeRequest(requestId);
    this.rejectMessage(source, data);
  };

  @action removeRequest = requestId => {
    delete this.requests[requestId];
    delete this.sources[requestId];

    // Create a new object to update mobx store
    this.requests = { ...this.requests };
  };

  getPermissionId = (method, appId) => `${method}:${appId}`; // Create an id to identify permissions based on method and appId

  getMethodFromRequest = requestId => {
    const { data: { method, params } } = this.requests[requestId];

    return method || params[0];
  }

  rejectMessage = (source, { id, from, method, token }) => {
    if (!source) {
      return;
    }

    source.postMessage(
      {
        error: `Method ${method} not allowed`,
        id,
        from: 'shell',
        result: null,
        to: from,
        token
      },
      '*'
    );
  };

  addAppPermission = (method, appId) => {
    this.permissions[this.getPermissionId(method, appId)] = true;
    this.savePermissions();
  };

  setPermissions = _permissions => {
    this.permissions = {
      ...this.permissions,
      ..._permissions
    };
    this.savePermissions();

    return true;
  };

  addMiddleware (middleware) {
    if (!middleware || typeof middleware !== 'function') {
      throw new Error('Interceptor middleware does not implement a function');
    }

    this.middleware.push(middleware);

    return true;
  }

  createToken = appId => {
    const token = sha3(`${appId}:${Date.now()}`);

    this.tokens[token] = appId;
    return token;
  };

  hasValidToken = (method, appId, token) => {
    if (!token) {
      return method === 'shell_requestNewToken';
    }

    return this.tokens[token] === appId;
  };

  hasTokenPermission = (method, token) => {
    return this.hasAppPermission(method, this.tokens[token]);
  };

  hasAppPermission = (method, appId) => {
    return !!this.permissions[this.getPermissionId(method, appId)];
  };

  savePermissions = () => {
    store.set(LS_PERMISSIONS, this.permissions);
  };

  _methodCallbackPost = (id, from, source, token) => {
    return (error, result) => {
      if (!source) {
        return;
      }
      source.postMessage(
        {
          error: error ? error.message : null,
          id,
          from: 'shell',
          to: from,
          result,
          token
        },
        '*'
      );
    };
  };

  executePubsubCall = ({ api, id, from, token, params }, source) => {
    const callback = this._methodCallbackPost(id, from, source, token);

    this.provider.subscribe(api, callback, params).then((result, error) => {
      this._methodCallbackPost(id, from, source, token)(null, result);
    });
  };

  executeMethodCall = ({ id, from, method, params, token }, source) => {
    try {
      const callback = this._methodCallbackPost(id, from, source, token);
      const isHandled = this.middleware.some(middleware => {
        try {
          return middleware(from, method, params, callback);
        } catch (error) {
          console.error(`Middleware error handling '${method}'`, error);
        }

        return false;
      });

      if (!isHandled) {
        this.provider.send(method, params, callback);
      }
    } catch (error) {
      console.error(`Execution error handling '${method}'`, error);
    }
  };

  receiveMessage = ({ data, source }) => {
    try {
      if (!data) {
        return;
      }

      const { from, method, to, token, params, api, subId, id } = data;

      if (to !== 'shell' || !from || from === 'shell') {
        return;
      }

      if (!this.hasValidToken(method, from, token)) {
        this.rejectMessage(source, data);
        return;
      }

      const _method = api ? params[0] : method;

      if (methodGroupFromMethod[_method] && !this.hasTokenPermission(_method, token)) {
        this.queueRequest(id, { // The requestId of a request is the id inside data
          data,
          source
        });
        return;
      }

      if (api) {
        this.executePubsubCall(data, source);
      } else if (subId) {
        const unsubscribePromise = subId === '*'
          ? this.provider.unsubscribeAll()
          : this.provider.unsubscribe(subId);

        unsubscribePromise
          .then(v =>
            this._methodCallbackPost(id, from, source, token)(null, v)
          );
      } else {
        this.executeMethodCall(data, source);
      }
    } catch (error) {
      console.error('Exception handling data', data, error);
    }
  };

  static instance = null;

  static create (provider) {
    if (!Store.instance) {
      Store.instance = new Store(provider);
    }

    return Store.instance;
  }

  static get () {
    return Store.instance;
  }
}
