import { HookContext, Application, createContext, getServiceOptions } from '@feathersjs/feathers';
import { NotFound, MethodNotAllowed, BadRequest } from '@feathersjs/errors';
import { createDebug } from '@feathersjs/commons';
import isEqual from 'lodash/isEqual';
import { CombinedChannel } from '../channels/channel/combined';
import { RealTimeConnection } from '../channels/channel/base';

const debug = createDebug('@feathersjs/transport-commons');

export const DEFAULT_PARAMS_POSITION = 1;

export const paramsPositions: { [key: string]: number } = {
  find: 0,
  update: 2,
  patch: 2
};

export function normalizeError (e: any) {
  const hasToJSON = typeof e.toJSON === 'function';
  const result = hasToJSON ? e.toJSON() : {};

  if (!hasToJSON) {
    Object.getOwnPropertyNames(e).forEach(key => {
      result[key] = e[key];
    });
  }

  if (process.env.NODE_ENV === 'production') {
    delete result.stack;
  }

  delete result.hook;

  return result;
}

export function getDispatcher (emit: string, socketMap: WeakMap<RealTimeConnection, any>, socketKey?: any) {
  return function (event: string, channel: CombinedChannel, context: HookContext, data?: any) {
    debug(`Dispatching '${event}' to ${channel.length} connections`);

    channel.connections.forEach(connection => {
      // The reference between connection and socket is set in `app.setup`
      const socket = socketKey ? connection[socketKey] : socketMap.get(connection);

      if (socket) {
        const eventName = `${context.path || ''} ${event}`.trim();

        let result = channel.dataFor(connection) || context.dispatch || context.result;

        // If we are getting events from an array but try to dispatch individual data
        // try to get the individual item to dispatch from the correct index.
        if (!Array.isArray(data) && Array.isArray(context.result) && Array.isArray(result)) {
          result = context.result.find(resultData => isEqual(resultData, data));
        }

        debug(`Dispatching '${eventName}' to Socket ${socket.id} with`, result);

        socket[emit](eventName, result);
      }
    });
  };
}

export async function runMethod (app: Application, connection: RealTimeConnection, path: string, method: string, args: any[]) {
  const trace = `method '${method}' on service '${path}'`;
  const methodArgs = args.slice(0);
  const callback = typeof methodArgs[methodArgs.length - 1] === 'function'
    ? methodArgs.pop() : function () {};

  debug(`Running ${trace}`, connection, args);

  const handleError = (error: any) => {
    debug(`Error in ${trace}`, error);
    callback(normalizeError(error));
  };

  try {
    const lookup = app.lookup(path);

    // No valid service was found throw a NotFound error
    if (lookup === null) {
      throw new NotFound(`Service '${path}' not found`);
    }

    const { service, params: route = {} } = lookup;
    const { methods } = getServiceOptions(service);

    // Only service methods are allowed
    if (!methods.includes(method)) {
      throw new MethodNotAllowed(`Method '${method}' not allowed on service '${path}'`);
    }

    const position = paramsPositions[method] !== undefined ? paramsPositions[method] : DEFAULT_PARAMS_POSITION;
    const query = methodArgs[position] || {};
    // `params` have to be re-mapped to the query and added with the route
    const params = Object.assign({ query, route, connection }, connection);

    // `params` is always the last parameter. Error if we got more arguments.
    if (methodArgs.length > (position + 1)) {
      throw new BadRequest(`Too many arguments for '${method}' method`);
    }

    methodArgs[position] = params;

    const ctx = createContext(service, method);
    const returnedCtx: HookContext = await (service as any)[method](...methodArgs, ctx);
    const result = returnedCtx.dispatch || returnedCtx.result;

    debug(`Returned successfully ${trace}`, result);
    callback(null, result);
  } catch (error: any) {
    handleError(error);
  }
}
