'use strict';

const authenticate = require('../authenticate');
const deprecate = require('util').deprecate;
const Logger = require('mongodb-core').Logger;
const MongoError = require('mongodb-core').MongoError;
const Mongos = require('../topologies/mongos');
const parse = require('mongodb-core').parseConnectionString;
const ReadPreference = require('mongodb-core').ReadPreference;
const ReplSet = require('../topologies/replset');
const Server = require('../topologies/server');
const ServerSessionPool = require('mongodb-core').Sessions.ServerSessionPool;

const monitoringEvents = [
  'timeout',
  'close',
  'serverOpening',
  'serverDescriptionChanged',
  'serverHeartbeatStarted',
  'serverHeartbeatSucceeded',
  'serverHeartbeatFailed',
  'serverClosed',
  'topologyOpening',
  'topologyClosed',
  'topologyDescriptionChanged',
  'commandStarted',
  'commandSucceeded',
  'commandFailed',
  'joined',
  'left',
  'ping',
  'ha',
  'all',
  'fullsetup',
  'open'
];
const ignoreOptionNames = ['native_parser'];
const legacyOptionNames = ['server', 'replset', 'replSet', 'mongos', 'db'];
const legacyParse = deprecate(
  require('../url_parser'),
  'current URL string parser is deprecated, and will be removed in a future version. ' +
    'To use the new parser, pass option { useNewUrlParser: true } to MongoClient.connect.'
);
const validOptionNames = [
  'poolSize',
  'ssl',
  'sslValidate',
  'sslCA',
  'sslCert',
  'sslKey',
  'sslPass',
  'sslCRL',
  'autoReconnect',
  'noDelay',
  'keepAlive',
  'keepAliveInitialDelay',
  'connectTimeoutMS',
  'family',
  'socketTimeoutMS',
  'reconnectTries',
  'reconnectInterval',
  'ha',
  'haInterval',
  'replicaSet',
  'secondaryAcceptableLatencyMS',
  'acceptableLatencyMS',
  'connectWithNoPrimary',
  'authSource',
  'w',
  'wtimeout',
  'j',
  'forceServerObjectId',
  'serializeFunctions',
  'ignoreUndefined',
  'raw',
  'bufferMaxEntries',
  'readPreference',
  'pkFactory',
  'promiseLibrary',
  'readConcern',
  'maxStalenessSeconds',
  'loggerLevel',
  'logger',
  'promoteValues',
  'promoteBuffers',
  'promoteLongs',
  'domainsEnabled',
  'checkServerIdentity',
  'validateOptions',
  'appname',
  'auth',
  'user',
  'password',
  'authMechanism',
  'compression',
  'fsync',
  'readPreferenceTags',
  'numberOfRetries',
  'auto_reconnect',
  'minSize',
  'monitorCommands',
  'retryWrites',
  'useNewUrlParser'
];

function addListeners(mongoClient, topology) {
  topology.on('authenticated', createListener(mongoClient, 'authenticated'));
  topology.on('error', createListener(mongoClient, 'error'));
  topology.on('timeout', createListener(mongoClient, 'timeout'));
  topology.on('close', createListener(mongoClient, 'close'));
  topology.on('parseError', createListener(mongoClient, 'parseError'));
  topology.once('open', createListener(mongoClient, 'open'));
  topology.once('fullsetup', createListener(mongoClient, 'fullsetup'));
  topology.once('all', createListener(mongoClient, 'all'));
  topology.on('reconnect', createListener(mongoClient, 'reconnect'));
}

function assignTopology(client, topology) {
  client.topology = topology;
  topology.s.sessionPool = new ServerSessionPool(topology.s.coreTopology);
}

// Clear out all events
function clearAllEvents(topology) {
  monitoringEvents.forEach(event => topology.removeAllListeners(event));
}

// Collect all events in order from SDAM
function collectEvents(mongoClient, topology) {
  const MongoClient = require('../mongo_client');
  const collectedEvents = [];

  if (mongoClient instanceof MongoClient) {
    monitoringEvents.forEach(event => {
      topology.on(event, (object1, object2) => {
        if (event === 'open') {
          collectedEvents.push({ event: event, object1: mongoClient });
        } else {
          collectedEvents.push({ event: event, object1: object1, object2: object2 });
        }
      });
    });
  }

  return collectedEvents;
}

/**
 * Connect to MongoDB using a url as documented at
 *
 *  docs.mongodb.org/manual/reference/connection-string/
 *
 * Note that for replicasets the replicaSet query parameter is required in the 2.0 driver
 *
 * @method
 * @param {MongoClient} mongoClient The MongoClient instance with which to connect.
 * @param {string} url The connection URI string
 * @param {object} [options] Optional settings. See MongoClient.prototype.connect for a list of options.
 * @param {MongoClient~connectCallback} [callback] The command result callback
 */
function connect(mongoClient, url, options, callback) {
  options = Object.assign({}, options);

  // If callback is null throw an exception
  if (callback == null) {
    throw new Error('no callback function provided');
  }

  // Get a logger for MongoClient
  const logger = Logger('MongoClient', options);

  // Did we pass in a Server/ReplSet/Mongos
  if (url instanceof Server || url instanceof ReplSet || url instanceof Mongos) {
    return connectWithUrl(mongoClient, url, options, connectCallback);
  }

  const parseFn = options.useNewUrlParser ? parse : legacyParse;
  const transform = options.useNewUrlParser ? transformUrlOptions : legacyTransformUrlOptions;

  parseFn(url, options, (err, _object) => {
    // Do not attempt to connect if parsing error
    if (err) return callback(err);

    // Flatten
    const object = transform(_object);

    // Parse the string
    const _finalOptions = createUnifiedOptions(object, options);

    // Check if we have connection and socket timeout set
    if (_finalOptions.socketTimeoutMS == null) _finalOptions.socketTimeoutMS = 360000;
    if (_finalOptions.connectTimeoutMS == null) _finalOptions.connectTimeoutMS = 30000;

    if (_finalOptions.db_options && _finalOptions.db_options.auth) {
      delete _finalOptions.db_options.auth;
    }

    // Store the merged options object
    mongoClient.s.options = _finalOptions;

    // Failure modes
    if (object.servers.length === 0) {
      return callback(new Error('connection string must contain at least one seed host'));
    }

    // Do we have a replicaset then skip discovery and go straight to connectivity
    if (_finalOptions.replicaSet || _finalOptions.rs_name) {
      return createTopology(
        mongoClient,
        'replicaset',
        _finalOptions,
        connectHandler(mongoClient, _finalOptions, connectCallback)
      );
    } else if (object.servers.length > 1) {
      return createTopology(
        mongoClient,
        'mongos',
        _finalOptions,
        connectHandler(mongoClient, _finalOptions, connectCallback)
      );
    } else {
      return createServer(
        mongoClient,
        _finalOptions,
        connectHandler(mongoClient, _finalOptions, connectCallback)
      );
    }
  });
  function connectCallback(err, topology) {
    const warningMessage = `seed list contains no mongos proxies, replicaset connections requires the parameter replicaSet to be supplied in the URI or options object, mongodb://server:port/db?replicaSet=name`;
    if (err && err.message === 'no mongos proxies found in seed list') {
      if (logger.isWarn()) {
        logger.warn(warningMessage);
      }

      // Return a more specific error message for MongoClient.connect
      return callback(new MongoError(warningMessage));
    }

    // Return the error and db instance
    callback(err, topology);
  }
}

function connectHandler(client, options, callback) {
  return (err, topology) => {
    if (err) {
      return handleConnectCallback(err, topology, callback);
    }

    // No authentication just reconnect
    if (!options.auth) {
      return handleConnectCallback(err, topology, callback);
    }

    // Authenticate
    authenticate(client, options.user, options.password, options, (err, success) => {
      if (success) {
        handleConnectCallback(null, topology, callback);
      } else {
        if (topology) topology.close();
        const authError = err ? err : new Error('Could not authenticate user ' + options.auth[0]);
        handleConnectCallback(authError, topology, callback);
      }
    });
  };
}

/**
 * Connect to MongoDB using a url as documented at
 *
 *  docs.mongodb.org/manual/reference/connection-string/
 *
 * Note that for replicasets the replicaSet query parameter is required in the 2.0 driver
 *
 * @method
 * @param {MongoClient} mongoClient The MongoClient instance with which to connect.
 * @param {MongoClient~connectCallback} [callback] The command result callback
 */
function connectOp(mongoClient, err, callback) {
  // Did we have a validation error
  if (err) return callback(err);
  // Fallback to callback based connect
  connect(mongoClient, mongoClient.s.url, mongoClient.s.options, err => {
    if (err) return callback(err);
    callback(null, mongoClient);
  });
}

function connectWithUrl(mongoClient, url, options, connectCallback) {
  // Set the topology
  assignTopology(mongoClient, url);

  // Add listeners
  addListeners(mongoClient, url);

  // Propagate the events to the client
  relayEvents(mongoClient, url);

  let finalOptions = Object.assign({}, options);

  // If we have a readPreference passed in by the db options, convert it from a string
  if (typeof options.readPreference === 'string' || typeof options.read_preference === 'string') {
    finalOptions.readPreference = new ReadPreference(
      options.readPreference || options.read_preference
    );
  }

  // Connect
  return url.connect(
    finalOptions,
    connectHandler(mongoClient, finalOptions, (err, topology) => {
      if (err) return connectCallback(err, topology);
      if (finalOptions.user || finalOptions.password || finalOptions.authMechanism) {
        return authenticate(
          mongoClient,
          finalOptions.user,
          finalOptions.password,
          finalOptions,
          err => {
            if (err) return connectCallback(err, topology);
            connectCallback(err, topology);
          }
        );
      }

      connectCallback(err, topology);
    })
  );
}

function createListener(mongoClient, event) {
  const eventSet = new Set(['all', 'fullsetup', 'open', 'reconnect']);
  return (v1, v2) => {
    if (eventSet.has(event)) {
      return mongoClient.emit(event, mongoClient);
    }

    mongoClient.emit(event, v1, v2);
  };
}

function createServer(mongoClient, options, callback) {
  // Pass in the promise library
  options.promiseLibrary = mongoClient.s.promiseLibrary;

  // Set default options
  const servers = translateOptions(options);

  const server = servers[0];

  // Propagate the events to the client
  const collectedEvents = collectEvents(mongoClient, server);

  // Connect to topology
  server.connect(options, (err, topology) => {
    if (err) {
      server.close(true);
      return callback(err);
    }
    // Clear out all the collected event listeners
    clearAllEvents(server);

    // Relay all the events
    relayEvents(mongoClient, server);
    // Add listeners
    addListeners(mongoClient, server);
    // Check if we are really speaking to a mongos
    const ismaster = topology.lastIsMaster();

    // Set the topology
    assignTopology(mongoClient, topology);

    // Do we actually have a mongos
    if (ismaster && ismaster.msg === 'isdbgrid') {
      // Destroy the current connection
      topology.close();
      // Create mongos connection instead
      return createTopology(mongoClient, 'mongos', options, callback);
    }

    // Fire all the events
    replayEvents(mongoClient, collectedEvents);
    // Otherwise callback
    callback(err, topology);
  });
}

function createTopology(mongoClient, topologyType, options, callback) {
  // Pass in the promise library
  options.promiseLibrary = mongoClient.s.promiseLibrary;

  const translationOptions = {};
  if (topologyType === 'unified') translationOptions.createServers = false;

  // Set default options
  const servers = translateOptions(options, translationOptions);

  // Create the topology
  let topology;
  if (topologyType === 'mongos') {
    topology = new Mongos(servers, options);
  } else if (topologyType === 'replicaset') {
    topology = new ReplSet(servers, options);
  }

  // Add listeners
  addListeners(mongoClient, topology);

  // Propagate the events to the client
  relayEvents(mongoClient, topology);

  // Open the connection
  topology.connect(options, (err, newTopology) => {
    if (err) {
      topology.close(true);
      return callback(err);
    }

    assignTopology(mongoClient, newTopology);
    callback(null, newTopology);
  });
}

function createUnifiedOptions(finalOptions, options) {
  const childOptions = [
    'mongos',
    'server',
    'db',
    'replset',
    'db_options',
    'server_options',
    'rs_options',
    'mongos_options'
  ];
  const noMerge = ['readconcern', 'compression'];

  for (const name in options) {
    if (noMerge.indexOf(name.toLowerCase()) !== -1) {
      finalOptions[name] = options[name];
    } else if (childOptions.indexOf(name.toLowerCase()) !== -1) {
      finalOptions = mergeOptions(finalOptions, options[name], false);
    } else {
      if (
        options[name] &&
        typeof options[name] === 'object' &&
        !Buffer.isBuffer(options[name]) &&
        !Array.isArray(options[name])
      ) {
        finalOptions = mergeOptions(finalOptions, options[name], true);
      } else {
        finalOptions[name] = options[name];
      }
    }
  }

  return finalOptions;
}

function handleConnectCallback(err, topology, callback) {
  return process.nextTick(() => {
    try {
      callback(err, topology);
    } catch (err) {
      if (topology) topology.close();
      throw err;
    }
  });
}

function legacyTransformUrlOptions(object) {
  return mergeOptions(createUnifiedOptions({}, object), object, false);
}

/**
 * Logout user from server, fire off on all connections and remove all auth info.
 *
 * @method
 * @param {MongoClient} mongoClient The MongoClient instance on which to logout.
 * @param {object} [options] Optional settings. See MongoClient.prototype.logout for a list of options.
 * @param {Db~resultCallback} [callback] The command result callback
 */
function logout(mongoClient, dbName, callback) {
  mongoClient.topology.logout(dbName, err => {
    if (err) return callback(err);
    callback(null, true);
  });
}

function mergeOptions(target, source, flatten) {
  for (const name in source) {
    if (source[name] && typeof source[name] === 'object' && flatten) {
      target = mergeOptions(target, source[name], flatten);
    } else {
      target[name] = source[name];
    }
  }

  return target;
}

function relayEvents(mongoClient, topology) {
  const serverOrCommandEvents = [
    'serverOpening',
    'serverDescriptionChanged',
    'serverHeartbeatStarted',
    'serverHeartbeatSucceeded',
    'serverHeartbeatFailed',
    'serverClosed',
    'topologyOpening',
    'topologyClosed',
    'topologyDescriptionChanged',
    'commandStarted',
    'commandSucceeded',
    'commandFailed',
    'joined',
    'left',
    'ping',
    'ha'
  ];

  serverOrCommandEvents.forEach(event => {
    topology.on(event, (object1, object2) => {
      mongoClient.emit(event, object1, object2);
    });
  });
}

//
// Replay any events due to single server connection switching to Mongos
//
function replayEvents(mongoClient, events) {
  for (let i = 0; i < events.length; i++) {
    mongoClient.emit(events[i].event, events[i].object1, events[i].object2);
  }
}

const LEGACY_OPTIONS_MAP = validOptionNames.reduce((obj, name) => {
  obj[name.toLowerCase()] = name;
  return obj;
}, {});

function transformUrlOptions(_object) {
  let object = Object.assign({ servers: _object.hosts }, _object.options);
  for (let name in object) {
    const camelCaseName = LEGACY_OPTIONS_MAP[name];
    if (camelCaseName) {
      object[camelCaseName] = object[name];
    }
  }
  if (_object.auth) {
    const auth = _object.auth;
    for (let i in auth) {
      if (auth[i]) {
        object[i] = auth[i];
      }
    }
    if (auth.username) {
      object.auth = auth;
      object.user = auth.username;
    }
    if (auth.db) {
      object.dbName = auth.db;
    }
  }
  if (object.maxpoolsize) {
    object.poolSize = object.maxpoolsize;
  }
  if (object.readconcernlevel) {
    object.readConcern = { level: object.readconcernlevel };
  }
  if (object.wtimeoutms) {
    object.wtimeout = object.wtimeoutms;
  }
  return object;
}

function translateOptions(options, translationOptions) {
  translationOptions = Object.assign({}, { createServers: true }, translationOptions);

  // If we have a readPreference passed in by the db options
  if (typeof options.readPreference === 'string' || typeof options.read_preference === 'string') {
    options.readPreference = new ReadPreference(options.readPreference || options.read_preference);
  }

  // Do we have readPreference tags, add them
  if (options.readPreference && (options.readPreferenceTags || options.read_preference_tags)) {
    options.readPreference.tags = options.readPreferenceTags || options.read_preference_tags;
  }

  // Do we have maxStalenessSeconds
  if (options.maxStalenessSeconds) {
    options.readPreference.maxStalenessSeconds = options.maxStalenessSeconds;
  }

  // Set the socket and connection timeouts
  if (options.socketTimeoutMS == null) options.socketTimeoutMS = 360000;
  if (options.connectTimeoutMS == null) options.connectTimeoutMS = 30000;

  if (!translationOptions.createServers) {
    return;
  }

  // Create server instances
  return options.servers.map(serverObj => {
    return serverObj.domain_socket
      ? new Server(serverObj.domain_socket, 27017, options)
      : new Server(serverObj.host, serverObj.port, options);
  });
}

// Validate options object
function validOptions(options) {
  const _validOptions = validOptionNames.concat(legacyOptionNames);

  for (const name in options) {
    if (ignoreOptionNames.indexOf(name) !== -1) {
      continue;
    }

    if (_validOptions.indexOf(name) === -1 && options.validateOptions) {
      return new MongoError(`option ${name} is not supported`);
    } else if (_validOptions.indexOf(name) === -1) {
      console.warn(`the options [${name}] is not supported`);
    }

    if (legacyOptionNames.indexOf(name) !== -1) {
      console.warn(
        `the server/replset/mongos/db options are deprecated, ` +
          `all their options are supported at the top level of the options object [${validOptionNames}]`
      );
    }
  }
}

module.exports = { connectOp, logout, validOptions };
