import ldapjs from 'ldapjs';
import { Log } from 'meteor/logging';

// copied from https://github.com/ldapjs/node-ldapjs/blob/a113953e0d91211eb945d2a3952c84b7af6de41c/lib/filters/index.js#L167
function escapedToHex (str) {
  if (str !== undefined) {
    return str.replace(/\\([0-9a-f][^0-9a-f]|[0-9a-f]$|[^0-9a-f]|$)/gi, function (match, p1) {
      if (!p1) {
        return '\\5c';
      }

      const hexCode = p1.charCodeAt(0).toString(16);
      const rest = p1.substring(1);
      return '\\' + hexCode + rest;
    });
  } else {
    return undefined;
  }
}

export default class LDAP {
  constructor() {
    this.ldapjs = ldapjs;

    this.connected = false;

    this.options = {
      host                               : this.constructor.settings_get('LDAP_HOST'),
      port                               : this.constructor.settings_get('LDAP_PORT'),
      Reconnect                          : this.constructor.settings_get('LDAP_RECONNECT'),
      timeout                            : this.constructor.settings_get('LDAP_TIMEOUT'),
      connect_timeout                    : this.constructor.settings_get('LDAP_CONNECT_TIMEOUT'),
      idle_timeout                       : this.constructor.settings_get('LDAP_IDLE_TIMEOUT'),
      encryption                         : this.constructor.settings_get('LDAP_ENCRYPTION'),
      ca_cert                            : this.constructor.settings_get('LDAP_CA_CERT'),
      reject_unauthorized                : this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') !== undefined ? this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') : true,
      Authentication                     : this.constructor.settings_get('LDAP_AUTHENTIFICATION'),
      Authentication_UserDN              : this.constructor.settings_get('LDAP_AUTHENTIFICATION_USERDN'),
      Authentication_Password            : this.constructor.settings_get('LDAP_AUTHENTIFICATION_PASSWORD'),
      Authentication_Fallback            : this.constructor.settings_get('LDAP_LOGIN_FALLBACK'),
      BaseDN                             : this.constructor.settings_get('LDAP_BASEDN'),
      Internal_Log_Level                 : this.constructor.settings_get('INTERNAL_LOG_LEVEL'), //this setting does not have any effect any more and should be deprecated
      User_Authentication                : this.constructor.settings_get('LDAP_USER_AUTHENTICATION'),
      User_Authentication_Field          : this.constructor.settings_get('LDAP_USER_AUTHENTICATION_FIELD'),
      User_Attributes                    : this.constructor.settings_get('LDAP_USER_ATTRIBUTES'),
      User_Search_Filter                 : escapedToHex(this.constructor.settings_get('LDAP_USER_SEARCH_FILTER')),
      User_Search_Scope                  : this.constructor.settings_get('LDAP_USER_SEARCH_SCOPE'),
      User_Search_Field                  : this.constructor.settings_get('LDAP_USER_SEARCH_FIELD'),
      Search_Page_Size                   : this.constructor.settings_get('LDAP_SEARCH_PAGE_SIZE'),
      Search_Size_Limit                  : this.constructor.settings_get('LDAP_SEARCH_SIZE_LIMIT'),
      group_filter_enabled               : this.constructor.settings_get('LDAP_GROUP_FILTER_ENABLE'),
      group_filter_object_class          : this.constructor.settings_get('LDAP_GROUP_FILTER_OBJECTCLASS'),
      group_filter_group_id_attribute    : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE'),
      group_filter_group_member_attribute: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE'),
      group_filter_group_member_format   : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT'),
      group_filter_group_name            : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_NAME'),
      AD_Simple_Auth                     : this.constructor.settings_get('LDAP_AD_SIMPLE_AUTH'),
      Default_Domain                     : this.constructor.settings_get('LDAP_DEFAULT_DOMAIN'),
    };
  }

  static settings_get(name, ...args) {
    let value = process.env[name];
    if (value !== undefined) {
      if (value === 'true' || value === 'false') {
        value = JSON.parse(value);
      } else if (value !== '' && !isNaN(value)) {
        value = Number(value);
      }
      return value;
    } else {
      //Log.warn(`Lookup for unset variable: ${name}`);
    }
  }

  connectSync(...args) {
     if (!this._connectSync) {
      this._connectSync = Meteor.wrapAsync(this.connectAsync, this);
    }
    return this._connectSync(...args);
  }

  searchAllSync(...args) {

    if (!this._searchAllSync) {
      this._searchAllSync = Meteor.wrapAsync(this.searchAllAsync, this);
    }
    return this._searchAllSync(...args);
  }

  connectAsync(callback) {
    Log.info('Init setup');

    let replied = false;

    const connectionOptions = {
      url           : `${this.options.host}:${this.options.port}`,
      timeout       : this.options.timeout,
      connectTimeout: this.options.connect_timeout,
      idleTimeout   : this.options.idle_timeout,
      reconnect     : this.options.Reconnect,
    };

    const tlsOptions = {
      rejectUnauthorized: this.options.reject_unauthorized,
    };

    if (this.options.ca_cert && this.options.ca_cert !== '') {
      // Split CA cert into array of strings
      const chainLines = this.constructor.settings_get('LDAP_CA_CERT').replace(/\\n/g,'\n').split('\n');
      let cert         = [];
      const ca         = [];
      chainLines.forEach((line) => {
        cert.push(line);
        if (line.match(/-END CERTIFICATE-/)) {
          ca.push(cert.join('\n'));
          cert = [];
        }
      });
      tlsOptions.ca = ca;
    }

    if (this.options.encryption === 'ssl') {
      connectionOptions.url        = `ldaps://${connectionOptions.url}`;
      connectionOptions.tlsOptions = tlsOptions;
    } else {
      connectionOptions.url = `ldap://${connectionOptions.url}`;
    }

    Log.info(`Connecting ${connectionOptions.url}`);
    Log.debug(`connectionOptions ${JSON.stringify(connectionOptions)}`);

    this.client = ldapjs.createClient(connectionOptions);

    this.bindSync = Meteor.wrapAsync(this.client.bind, this.client);

    this.client.on('error', (error) => {
      Log.error(`connection ${error}`);
      if (replied === false) {
        replied = true;
        callback(error, null);
      }
    });

    this.client.on('idle', () => {
      Log.info('Idle');
      this.disconnect();
    });

    this.client.on('close', () => {
      Log.info('Closed');
    });

    if (this.options.encryption === 'tls') {
      // Set host parameter for tls.connect which is used by ldapjs starttls. This shouldn't be needed in newer nodejs versions (e.g v5.6.0).
      // https://github.com/RocketChat/Rocket.Chat/issues/2035
      // https://github.com/mcavage/node-ldapjs/issues/349
      tlsOptions.host = this.options.host;

      Log.info('Starting TLS');
      Log.debug(`tlsOptions ${JSON.stringify(tlsOptions)}`);

      this.client.starttls(tlsOptions, null, (error, response) => {
        if (error) {
          Log.error(`TLS connection ${JSON.stringify(error)}`);
          if (replied === false) {
            replied = true;
            callback(error, null);
          }
          return;
        }

        Log.info('TLS connected');
        this.connected = true;
        if (replied === false) {
          replied = true;
          callback(null, response);
        }
      });
    } else {
      this.client.on('connect', (response) => {
        Log.info('LDAP connected');
        this.connected = true;
        if (replied === false) {
          replied = true;
          callback(null, response);
        }
      });
    }

    setTimeout(() => {
      if (replied === false) {
        Log.error(`connection time out ${connectionOptions.connectTimeout}`);
        replied = true;
        callback(new Error('Timeout'));
      }
    }, connectionOptions.connectTimeout);
  }

  getUserFilter(username) {
    const filter = [];

    if (this.options.User_Search_Filter !== '') {
      if (this.options.User_Search_Filter[0] === '(') {
        filter.push(`${this.options.User_Search_Filter}`);
      } else {
        filter.push(`(${this.options.User_Search_Filter})`);
      }
    }

    const usernameFilter = this.options.User_Search_Field.split(',').map((item) => `(${item}=${username})`);

    if (usernameFilter.length === 0) {
      Log.error('LDAP_LDAP_User_Search_Field not defined');
    } else if (usernameFilter.length === 1) {
      filter.push(`${usernameFilter[0]}`);
    } else {
      filter.push(`(|${usernameFilter.join('')})`);
    }

    return `(&${filter.join('')})`;
  }

  bindUserIfNecessary(username, password) {

    if (this.domainBinded === true) {
      return;
    }

    if (!this.options.User_Authentication) {
      return;
    }

    /* if SimpleAuth is configured, the BaseDN is not needed */
    if (!this.options.BaseDN && !this.options.AD_Simple_Auth) throw new Error('BaseDN is not provided');

    var userDn = "";
    if (this.options.AD_Simple_Auth === true || this.options.AD_Simple_Auth === 'true') {
      userDn = `${username}@${this.options.Default_Domain}`;
    } else {
      userDn = `${this.options.User_Authentication_Field}=${username},${this.options.BaseDN}`;
    }

    Log.info(`Binding with User ${userDn}`);

    this.bindSync(userDn, password);
    this.domainBinded = true;
  }

  bindIfNecessary() {
    if (this.domainBinded === true) {
      return;
    }

    if (this.options.Authentication !== true) {
      return;
    }

    Log.info(`Binding UserDN ${this.options.Authentication_UserDN}`);

    this.bindSync(this.options.Authentication_UserDN, this.options.Authentication_Password);
    this.domainBinded = true;
  }

  searchUsersSync(username, page) {
    this.bindIfNecessary();
    const searchOptions = {
      filter   : this.getUserFilter(username),
      scope    : this.options.User_Search_Scope || 'sub',
      sizeLimit: this.options.Search_Size_Limit,
    };

    if (!!this.options.User_Attributes) searchOptions.attributes = this.options.User_Attributes.split(',');

    if (this.options.Search_Page_Size > 0) {
      searchOptions.paged = {
        pageSize : this.options.Search_Page_Size,
        pagePause: !!page,
      };
    }

    Log.info(`Searching user ${username}`);
    Log.debug(`searchOptions ${searchOptions}`);
    Log.debug(`BaseDN ${this.options.BaseDN}`);

    if (page) {
      return this.searchAllPaged(this.options.BaseDN, searchOptions, page);
    }

    return this.searchAllSync(this.options.BaseDN, searchOptions);
  }

  getUserByIdSync(id, attribute) {
    this.bindIfNecessary();

    const Unique_Identifier_Field = this.constructor.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD').split(',');

    let filter;

    if (attribute) {
      filter = new this.ldapjs.filters.EqualityFilter({
        attribute,
        value: Buffer.from(id, 'hex'),
      });
    } else {
      const filters = [];
      Unique_Identifier_Field.forEach((item) => {
        filters.push(new this.ldapjs.filters.EqualityFilter({
          attribute: item,
          value    : Buffer.from(id, 'hex'),
        }));
      });

      filter = new this.ldapjs.filters.OrFilter({ filters });
    }

    const searchOptions = {
      filter,
      scope: 'sub',
    };

    Log.info(`Searching by id ${id}`);
    Log.debug(`search filter ${searchOptions.filter.toString()}`);
    Log.debug(`BaseDN ${this.options.BaseDN}`);

    const result = this.searchAllSync(this.options.BaseDN, searchOptions);

    if (!Array.isArray(result) || result.length === 0) {
      return;
    }

    if (result.length > 1) {
      Log.error(`Search by id ${id} returned ${result.length} records`);
    }

    return result[0];
  }

  getUserByUsernameSync(username) {
    this.bindIfNecessary();

    const searchOptions = {
      filter: this.getUserFilter(username),
      scope : this.options.User_Search_Scope || 'sub',
    };

    Log.info(`Searching user ${username}`);
    Log.debug(`searchOptions ${searchOptions}`);
    Log.debug(`BaseDN ${this.options.BaseDN}`);

    const result = this.searchAllSync(this.options.BaseDN, searchOptions);

    if (!Array.isArray(result) || result.length === 0) {
      return;
    }

    if (result.length > 1) {
      Log.error(`Search by username ${username} returned ${result.length} records`);
    }

    return result[0];
  }

  getUserGroups(username, ldapUser) {
    if (!this.options.group_filter_enabled) {
      return true;
    }

    const filter = ['(&'];

    if (this.options.group_filter_object_class !== '') {
      filter.push(`(objectclass=${this.options.group_filter_object_class})`);
    }

    if (this.options.group_filter_group_member_attribute !== '') {
      const format_value = ldapUser[this.options.group_filter_group_member_format];
      if (format_value) {
        filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
      }
    }

    filter.push(')');

    const searchOptions = {
      filter: filter.join('').replace(/#{username}/g, username).replace("\\", "\\\\"),
      scope : 'sub',
    };

    Log.debug(`Group list filter LDAP: ${searchOptions.filter}`);

    const result = this.searchAllSync(this.options.BaseDN, searchOptions);

    if (!Array.isArray(result) || result.length === 0) {
      return [];
    }

    const grp_identifier = this.options.group_filter_group_id_attribute || 'cn';
    const groups         = [];
    result.map((item) => {
      groups.push(item[grp_identifier]);
    });
    Log.debug(`Groups: ${groups.join(', ')}`);
    return groups;

  }

  isUserInGroup(username, ldapUser) {
    if (!this.options.group_filter_enabled) {
      return true;
    }

    const grps = this.getUserGroups(username, ldapUser);

    const filter = ['(&'];

    if (this.options.group_filter_object_class !== '') {
      filter.push(`(objectclass=${this.options.group_filter_object_class})`);
    }

    if (this.options.group_filter_group_member_attribute !== '') {
      const format_value = ldapUser[this.options.group_filter_group_member_format];
      if (format_value) {
        filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
      }
    }

    if (this.options.group_filter_group_id_attribute !== '') {
      filter.push(`(${this.options.group_filter_group_id_attribute}=${this.options.group_filter_group_name})`);
    }
    filter.push(')');

    const searchOptions = {
      filter: filter.join('').replace(/#{username}/g, username).replace("\\", "\\\\"),
      scope : 'sub',
    };

    Log.debug(`Group filter LDAP: ${searchOptions.filter}`);

    const result = this.searchAllSync(this.options.BaseDN, searchOptions);

    if (!Array.isArray(result) || result.length === 0) {
      return false;
    }
    return true;
  }

  extractLdapEntryData(entry) {
    const values = {
      _raw: entry.raw,
    };

    Object.keys(values._raw).forEach((key) => {
      const value = values._raw[key];

      if (!['thumbnailPhoto', 'jpegPhoto'].includes(key)) {
        if (value instanceof Buffer) {
          values[key] = value.toString();
        } else {
          values[key] = value;
        }
      }
    });

    return values;
  }

  searchAllPaged(BaseDN, options, page) {
    this.bindIfNecessary();

    const processPage = ({ entries, title, end, next }) => {
      Log.info(title);
      // Force LDAP idle to wait the record processing
      this.client._updateIdle(true);
      page(null, entries, {
        end, next: () => {
          // Reset idle timer
          this.client._updateIdle();
          next && next();
        }
      });
    };

    this.client.search(BaseDN, options, (error, res) => {
      if (error) {
        Log.error(error);
        page(error);
        return;
      }

      res.on('error', (error) => {
        Log.error(error);
        page(error);
        return;
      });

      let entries = [];

      const internalPageSize = options.paged && options.paged.pageSize > 0 ? options.paged.pageSize * 2 : 500;

      res.on('searchEntry', (entry) => {
        entries.push(this.extractLdapEntryData(entry));

        if (entries.length >= internalPageSize) {
          processPage({
            entries,
            title: 'Internal Page',
            end  : false,
          });
          entries = [];
        }
      });

      res.on('page', (result, next) => {
        if (!next) {
          this.client._updateIdle(true);
          processPage({
            entries,
            title: 'Final Page',
            end  : true,
          });
        } else if (entries.length) {
          Log.info('Page');
          processPage({
            entries,
            title: 'Page',
            end  : false,
            next,
          });
          entries = [];
        }
      });

      res.on('end', () => {
        if (entries.length) {
          processPage({
            entries,
            title: 'Final Page',
            end  : true,
          });
          entries = [];
        }
      });
    });
  }

  searchAllAsync(BaseDN, options, callback) {
    this.bindIfNecessary();

    this.client.search(BaseDN, options, (error, res) => {
      if (error) {
        Log.error(error);
        callback(error);
        return;
      }

      res.on('error', (error) => {
        Log.error(error);
        callback(error);
        return;
      });

      const entries = [];

      res.on('searchEntry', (entry) => {
        entries.push(this.extractLdapEntryData(entry));
      });

      res.on('end', () => {
        Log.info(`Search result count ${entries.length}`);
        callback(null, entries);
      });
    });
  }

  authSync(dn, password) {
    Log.info(`Authenticating ${dn}`);

    try {
      if (password === '') {
        throw new Error('Password is not provided');
      }
      this.bindSync(dn, password);
      Log.info(`Authenticated ${dn}`);
      return true;
    } catch (error) {
      Log.info(`Not authenticated ${dn}`);
      Log.debug('error', error);
      return false;
    }
  }

  disconnect() {
    this.connected    = false;
    this.domainBinded = false;
    Log.info('Disconecting');
    this.client.unbind();
  }
}
