cqi.api.APIClient = class APIClient {
  /**
   * @param {string} host
   * @param {number} [timeout=60] timeout
   * @param {string} [version=0.1] version
   */
  constructor(host, timeout = 60, version = '0.1') {
    this.host = host;
    this.timeout = timeout * 1000;  // convert seconds to milliseconds
    this.version = version;
    this.socket = io(
      this.host,
      {
        transports: ['websocket'],
        upgrade: false
      }
    );
  }

  /**
   * @param {string} fn_name
   * @param {object} [fn_args={}]
   * @returns {Promise}
   */
  async #request(fn_name, fn_args = {}) {
    // TODO: implement timeout
    let response = await this.socket.emitWithAck('exec', fn_name, fn_args);
    if (response.code === 200) {
      return response.payload;
    } else if (response.code === 500) {
      throw new Error(`[${response.code}] ${response.msg}`);
    } else if (response.code === 502) {
      if (response.payload.code in cqi.errors.lookup) {
        throw new cqi.errors.lookup[response.payload.code]();
      } else {
        throw new cqi.errors.CQiError();
      }
    }
  }

  /**
   * @param {string} username
   * @param {string} password
   * @returns {Promise<cqi.status.StatusConnectOk>}
   */
  async ctrl_connect(username, password) {
    const fn_name = 'ctrl_connect';
    const fn_args = {username: username, password: password};
    let payload = await this.#request(fn_name, fn_args);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * @returns {Promise<cqi.status.StatusByeOk>}
   */
  async ctrl_bye() {
    const fn_name = 'ctrl_bye';
    let payload = await this.#request(fn_name);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * @returns {Promise<null>}
   */
  async ctrl_user_abort() {
    const fn_name = 'ctrl_user_abort';
    return await this.#request(fn_name);
  }

  /**
   * @returns {Promise<cqi.status.StatusPingOk>}
   */
  async ctrl_ping() {
    const fn_name = 'ctrl_ping';
    let payload = await this.#request(fn_name);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * Full-text error message for the last general error reported
   * by the CQi server
   * 
   * @returns {Promise<string>}
   */
  async ctrl_last_general_error() {
    const fn_name = 'ctrl_last_general_error';
    return await this.#request(fn_name);
  }

  /**
   * @returns {Promise<boolean>}
   */
  async ask_feature_cqi_1_0() {
    const fn_name = 'ask_feature_cqi_1_0';
    return await this.#request(fn_name);
  }

  /**
   * @returns {Promise<boolean>}
   */
  async ask_feature_cl_2_3() {
    const fn_name = 'ask_feature_cl_2_3';
    return await this.#request(fn_name);
  }

  /**
   * @returns {Promise<boolean>}
   */
  async ask_feature_cqp_2_3() {
    const fn_name = 'ask_feature_cqp_2_3';
    return await this.#request(fn_name);
  }

  /**
   * @returns {Promise<string[]>}
   */
  async corpus_list_corpora() {
    const fn_name = 'corpus_list_corpora';
    return await this.#request(fn_name);
  }

  /**
   * @param {string} corpus 
   * @returns {Promise<string>}
   */
  async corpus_charset(corpus) {
    const fn_name = 'corpus_charset';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} corpus 
   * @returns {Promise<string[]>}
   */
  async corpus_properties(corpus) {
    const fn_name = 'corpus_properties';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} corpus
   * @returns {Promise<string[]>}
   */
  async corpus_positional_attributes(corpus) {
    const fn_name = 'corpus_positional_attributes';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} corpus
   * @returns {Promise<string[]>}
   */
  async corpus_structural_attributes(corpus) {
    const fn_name = 'corpus_structural_attributes';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} corpus
   * @param {string} attribute
   * @returns {Promise<boolean>}
   */
  async corpus_structural_attribute_has_values(corpus, attribute) {
    const fn_name = 'corpus_structural_attribute_has_values';
    const fn_args = {corpus: corpus, attribute: attribute};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} corpus
   * @returns {Promise<string[]>}
   */
  async corpus_alignment_attributes(corpus) {
    const fn_name = 'corpus_alignment_attributes';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * the full name of <corpus> as specified in its registry entry
   * 
   * @param {string} corpus
   * @returns {Promise<string>}
   */
  async corpus_full_name(corpus) {
    const fn_name = 'corpus_full_name';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns the contents of the .info file of <corpus> as a list of lines
   * 
   * @param {string} corpus
   * @returns {Promise<string[]>}
   */
  async corpus_info(corpus) {
    const fn_name = 'corpus_info';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * try to unload a corpus and all its attributes from memory
   * 
   * @param {string} corpus
   * @returns {Promise<cqi.status.StatusOk>}
   */
  async corpus_drop_corpus(corpus) {
    const fn_name = 'corpus_drop_corpus';
    const fn_args = {corpus: corpus};
    let payload = await this.#request(fn_name, fn_args);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * returns the size of <attribute>:
   * - number of tokens        (positional)
   * - number of regions       (structural)
   * - number of alignments    (alignment)
   * 
   * @param {string} attribute
   * @returns {Promise<number>}
   */
  async cl_attribute_size(attribute) {
    const fn_name = 'cl_attribute_size';
    const fn_args = {attribute: attribute};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns the number of entries in the lexicon of a positional attribute;
   *
   * valid lexicon IDs range from 0 .. (lexicon_size - 1)
   * 
   * @param {string} attribute
   * @returns {Promise<number>}
   */
  async cl_lexicon_size(attribute) {
    const fn_name = 'cl_lexicon_size';
    const fn_args = {attribute: attribute};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * unload attribute from memory
   * 
   * @param {string} attribute
   * @returns {Promise<cqi.status.StatusOk>}
   */
  async cl_drop_attribute(attribute) {
    const fn_name = 'cl_drop_attribute';
    const fn_args = {attribute: attribute};
    let payload = await this.#request(fn_name, fn_args);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * NOTE: simple (scalar) mappings are applied to lists (the returned list
   *       has exactly the same length as the list passed as an argument)
   */

  /**
   * returns -1 for every string in <strings> that is not found in the lexicon
   * 
   * @param {string} attribute
   * @param {strings[]} string
   * @returns {Promise<number[]>}
   */
  async cl_str2id(attribute, strings) {
    const fn_name = 'cl_str2id';
    const fn_args = {attribute: attribute, strings: strings};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns "" for every ID in <id> that is out of range
   * 
   * @param {string} attribute
   * @param {number[]} id
   * @returns {Promise<string[]>}
   */
  async cl_id2str(attribute, id) {
    const fn_name = 'cl_id2str';
    const fn_args = {attribute: attribute, id: id};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns 0 for every ID in <id> that is out of range
   * 
   * @param {string} attribute
   * @param {number[]} id
   * @returns {Promise<number[]>}
   */
  async cl_id2freq(attribute, id) {
    const fn_name = 'cl_id2freq';
    const fn_args = {attribute: attribute, id: id};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns -1 for every corpus position in <cpos> that is out of range
   * 
   * @param {string} attribute
   * @param {number[]} cpos
   * @returns {Promise<number[]>}
   */
  async cl_cpos2id(attribute, cpos) {
    const fn_name = 'cl_cpos2id';
    const fn_args = {attribute: attribute, cpos: cpos};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns "" for every corpus position in <cpos> that is out of range
   * 
   * @param {string} attribute
   * @param {number[]} cpos
   * @returns {Promise<string[]>}
   */
  async cl_cpos2str(attribute, cpos) {
    const fn_name = 'cl_cpos2str';
    const fn_args = {attribute: attribute, cpos: cpos};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns -1 for every corpus position not inside a structure region
   * 
   * @param {string} attribute
   * @param {number[]} cpos
   * @returns {Promise<number[]>}
   */
  async cl_cpos2struc(attribute, cpos) {
    const fn_name = 'cl_cpos2struc';
    const fn_args = {attribute: attribute, cpos: cpos};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * NOTE: temporary addition for the Euralex2000 tutorial, but should
   * probably be included in CQi specs
   */

  /**
   * returns left boundary of s-attribute region enclosing cpos,
   * -1 if not in region
   * 
   * @param {string} attribute
   * @param {number[]} cpos
   * @returns {Promise<number[]>}
   */
  async cl_cpos2lbound(attribute, cpos) {
    const fn_name = 'cl_cpos2lbound';
    const fn_args = {attribute: attribute, cpos: cpos};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns right boundary of s-attribute region enclosing cpos,
   * -1 if not in region
   * 
   * @param {string} attribute
   * @param {number[]} cpos
   * @returns {Promise<number[]>}
   */
  async cl_cpos2rbound(attribute, cpos) {
    const fn_name = 'cl_cpos2rbound';
    const fn_args = {attribute: attribute, cpos: cpos};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns -1 for every corpus position not inside an alignment
   * 
   * @param {string} attribute
   * @param {number[]} cpos
   * @returns {Promise<number[]>}
   */
  async cl_cpos2alg(attribute, cpos) {
    const fn_name = 'cl_cpos2alg';
    const fn_args = {attribute: attribute, cpos: cpos};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns annotated string values of structure regions in <strucs>;
   * "" if out of range
   *
   * check corpus_structural_attribute_has_values(<attribute>) first
   * 
   * @param {string} attribute
   * @param {number[]} strucs
   * @returns {Promise<string[]>}
   */
  async cl_struc2str(attribute, strucs) {
    const fn_name = 'cl_struc2str';
    const fn_args = {attribute: attribute, strucs: strucs};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * NOTE: the following mappings take a single argument and return multiple
   * values, including lists of arbitrary size
   */

  /**
   * returns all corpus positions where the given token occurs
   * 
   * @param {string} attribute
   * @param {number} id
   * @returns {Promise<number[]>}
   */
  async cl_id2cpos(attribute, id) {
    const fn_name = 'cl_id2cpos';
    const fn_args = {attribute: attribute, id: id};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns all corpus positions where one of the tokens in <id_list> occurs;
   * the returned list is sorted as a whole, not per token id
   * 
   * @param {string} attribute
   * @param {number[]} id_list
   * @returns {Promise<number[]>}
   */
  async cl_idlist2cpos(attribute, id_list) {
    const fn_name = 'cl_idlist2cpos';
    const fn_args = {attribute: attribute, id_list: id_list};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns lexicon IDs of all tokens that match <regex>;
   * the returned list may be empty (size 0);
   * 
   * @param {string} attribute
   * @param {string} regex
   * @returns {Promise<number[]>}
   */
  async cl_regex2id(attribute, regex) {
    const fn_name = 'cl_regex2id';
    const fn_args = {attribute: attribute, regex: regex};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns start and end corpus positions of structure region <struc>
   * 
   * @param {string} attribute
   * @param {number} struc
   * @returns {Promise<[number, number]>}
   */
  async cl_struc2cpos(attribute, struc) {
    const fn_name = 'cl_struc2cpos';
    const fn_args = {attribute: attribute, struc: struc};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * returns (src_start, src_end, target_start, target_end)
   * 
   * @param {string} attribute
   * @param {number} alg
   * @returns {Promise<[number, number, number, number]>}
   */
  async alg2cpos(attribute, alg) {
    const fn_name = 'alg2cpos';
    const fn_args = {attribute: attribute, alg: alg};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * <query> must include the ';' character terminating the query.
   * 
   * @param {string} mother_corpus
   * @param {string} subcorpus_name
   * @param {string} query
   * @returns {Promise<cqi.status.StatusOk>}
   */
  async cqp_query(mother_corpus, subcorpus_name, query) {
    const fn_name = 'cqp_query';
    const fn_args = {mother_corpus: mother_corpus, subcorpus_name: subcorpus_name, query: query};
    let payload = await this.#request(fn_name, fn_args);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * @param {string} corpus
   * @returns {Promise<string[]>}
   */
  async cqp_list_subcorpora(corpus) {
    const fn_name = 'cqp_list_subcorpora';
    const fn_args = {corpus: corpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} subcorpus
   * @returns {Promise<number>}
   */
  async cqp_subcorpus_size(subcorpus) {
    const fn_name = 'cqp_subcorpus_size';
    const fn_args = {subcorpus: subcorpus};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} subcorpus
   * @param {number} field
   * @returns {Promise<boolean>}
   */
  async cqp_subcorpus_has_field(subcorpus, field) {
    const fn_name = 'cqp_subcorpus_has_field';
    const fn_args = {subcorpus: subcorpus, field: field};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * Dump the values of <field> for match ranges <first> .. <last>
   * in <subcorpus>. <field> is one of the CQI_CONST_FIELD_* constants.
   * 
   * @param {string} subcorpus
   * @param {number} field
   * @param {number} first
   * @param {number} last
   * @returns {Promise<number[]>}
   */
  async cqp_dump_subcorpus(subcorpus, field, first, last) {
    const fn_name = 'cqp_dump_subcorpus';
    const fn_args = {subcorpus: subcorpus, field: field, first: first, last: last};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * delete a subcorpus from memory
   * 
   * @param {string} subcorpus
   * @returns {Promise<cqi.status.StatusOk>}
   */
  async cqp_drop_subcorpus(subcorpus) {
    const fn_name = 'cqp_drop_subcorpus';
    const fn_args = {subcorpus: subcorpus};
    let payload = await this.#request(fn_name, fn_args);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * NOTE: The following two functions are temporarily included for the
   * Euralex 2000 tutorial demo
   */

  /**
   * frequency distribution of single tokens
   *
   * returns <n> (id, frequency) pairs flattened into a list of size 2*<n>
   * field is one of
   * - CQI_CONST_FIELD_MATCH
   * - CQI_CONST_FIELD_TARGET
   * - CQI_CONST_FIELD_KEYWORD
   *
   * NB: pairs are sorted by frequency desc.
   * 
   * @param {string} subcorpus
   * @param {number} cutoff
   * @param {number} field
   * @param {string} attribute
   * @returns {Promise<number[]>}
   */
  async cqp_fdist_1(subcorpus, cutoff, field, attribute) {
    const fn_name = 'cqp_fdist_1';
    const fn_args = {subcorpus: subcorpus, cutoff: cutoff, field: field, attribute: attribute};
    return await this.#request(fn_name, fn_args);
  }

  /**
   * frequency distribution of pairs of tokens
   *
   * returns <n> (id1, id2, frequency) pairs flattened into a list of
   * size 3*<n>
   *
   * NB: triples are sorted by frequency desc.
   * 
   * @param {string} subcorpus
   * @param {number} cutoff
   * @param {number} field1
   * @param {string} attribute1
   * @param {number} field2
   * @param {string} attribute2
   * @returns {Promise<number[]>}
   */
  async cqp_fdist_2(subcorpus, cutoff, field1, attribute1, field2, attribute2) {
    const fn_name = 'cqp_fdist_2';
    const fn_args = {subcorpus: subcorpus, cutoff: cutoff, field1: field1, attribute1: attribute1, field2: field2, attribute2: attribute2};
    return await this.#request(fn_name, fn_args);
  }

  /**************************************************************************
   * NOTE: The following is not included in the CQi specification.          *
   **************************************************************************/
  /**************************************************************************
   *                      Custom additions for nopaque                      *
   **************************************************************************/

  /**
   * @param {string} corpus
   * @returns {Promise<cqi.status.StatusOk>}
   */
  async ext_corpus_update_db(corpus) {
    const fn_name = 'ext_corpus_update_db';
    const fn_args = {corpus: corpus};
    let payload = await this.#request(fn_name, fn_args);
    return new cqi.status.lookup[payload.code]();
  }

  /**
   * @param {string} corpus
   * @returns {Promise<object>}
   */
  async ext_corpus_static_data(corpus) {
    const fn_name = 'ext_corpus_static_data';
    const fn_args = {corpus: corpus};
    let compressedEncodedData = await this.#request(fn_name, fn_args);
    let data = pako.inflate(compressedEncodedData, {to: 'string'});
    return JSON.parse(data);
  }

  /**
   * @param {string} corpus
   * @param {number=} page
   * @param {number=} per_page
   * @returns {Promise<object>}
   */
  async ext_corpus_paginate_corpus(corpus, page, per_page) {
    const fn_name = 'ext_corpus_paginate_corpus';
    const fn_args = {corpus: corpus}
    if (page !== undefined) {fn_args.page = page;}
    if (per_page !== undefined) {fn_args.per_page = per_page;}
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} subcorpus
   * @param {number=} context
   * @param {number=} page
   * @param {number=} per_page
   * @returns {Promise<object>}
   */
  async ext_cqp_paginate_subcorpus(subcorpus, context, page, per_page) {
    const fn_name = 'ext_cqp_paginate_subcorpus';
    const fn_args = {subcorpus: subcorpus}
    if (context !== undefined) {fn_args.context = context;}
    if (page !== undefined) {fn_args.page = page;}
    if (per_page !== undefined) {fn_args.per_page = per_page;}
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} subcorpus
   * @param {number[]} match_id_list
   * @param {number=} context
   * @returns {Promise<object>}
   */
  async ext_cqp_partial_export_subcorpus(subcorpus, match_id_list, context) {
    const fn_name = 'ext_cqp_partial_export_subcorpus';
    const fn_args = {subcorpus: subcorpus, match_id_list: match_id_list};
    if (context !== undefined) {fn_args.context = context;}
    return await this.#request(fn_name, fn_args);
  }

  /**
   * @param {string} subcorpus
   * @param {number=} context
   * @returns {Promise<object>}
   */
  async ext_cqp_export_subcorpus(subcorpus, context) {
    const fn_name = 'ext_cqp_export_subcorpus';
    const fn_args = {subcorpus: subcorpus};
    if (context !== undefined) {fn_args.context = context;}
    return await this.#request(fn_name, fn_args);
  }
};